ADR 0107: Nil and Type Patterns in match:

Status

Accepted (2026-07-12)

Implementation Tracking

Epic: BT-2853 Issues:

PhaseIssueTitleSizeBlocked by
1BT-2854Foundation: Pattern::Nil + Pattern::Type AST/parser/leaf-gateM
2BT-2855Pattern::Type: bindings, narrowing, and codegenLBT-2854
3BT-2856Exhaustiveness + hot-reload re-check integrationMBT-2855
4BT-2857Docs, LSP, and E2E validationMBT-2855, BT-2856

Status: Planned

Context

Problem statement

Beamtalk code that needs to branch on both the nilness and the runtime class/shape of a single value has no construct for it other than sequential guard clauses. Two real examples from a consuming application (beamtalk-symphony):

workspaceRoot -> String =>
  defaultRoot := File tempDirectory ++ "/symphony_workspaces"
  raw := self nested: "workspace" key: "root" default: nil
  raw isNil ifTrue: [^defaultRoot]
  (raw isKindOf: String)
    ifTrue: [
      (raw startsWith: "$")
        ifTrue: [
          resolved := self resolveEnv: raw
          resolved isNil ifFalse: [^self expandHome: resolved]
          ^raw
        ]
      ^self expandHome: raw
    ]
  defaultRoot
renderForBody: body :: String items: coll :: Printable | Nil itemName: itemName :: String vars: vars :: Dictionary -> String =>
  coll isNil ifTrue: [^""]
  (coll isKindOf: List) ifFalse: [^""]
  items :: List(Printable) := coll
  items inject: "" into: [:acc :item | ...]

Both are guard-clause chains with early returns — functionally correct, but every case the value can take (nil, matching-shape, non-matching-shape) is implicit in a sequence of ifTrue:/ifFalse: branches rather than visible as a flat, exhaustively-checkable list.

Current state

Constraints

Decision

Extend match: with two new pattern kinds, split into two phases so the low-risk, immediately-useful part ships without waiting on the harder part.

Phase A (this ADR): nil pattern + concrete-type patterns

raw match: [
  nil -> defaultRoot;
  path :: String when: [path startsWith: "$"] ->
    (self resolveEnv: path) ifNil: [path] ifNotNil: [:resolved | self expandHome: resolved];
  path :: String -> self expandHome: path;
  _ -> defaultRoot
]
coll match: [
  nil -> "";
  items :: List -> (items inject: "" into: [:acc :item | ...]);
  _ -> ""
]

Not yet supported: an earlier draft of this example wrote items :: List(Printable). Generic type arguments in a type pattern are not supported — class in Pattern::Type is scoped to a bare identifier (BT-2854's explicit acceptance criteria), and per ADR 0068 type erasure there is no reified generic tag to check at runtime regardless, so List(Printable) in pattern position produces a dedicated diagnostic rather than silently checking only List (BT-2860). Callers needing to verify element type can hand-verify inside the arm body (e.g. a when: guard with allSatisfy:).

Phase B (explicitly out of scope here): subclass-polymorphic matching

binding :: Shape where Shape has subclasses Circle/Square needs either compile-time subclass enumeration (using the class-hierarchy data already available in semantic_analysis) or a wrapped runtime dispatch call — genuinely new codegen/design work, not reuse of an existing mechanism (see Prior Art and Implementation). Deferred to a follow-up ADR once Phase A has shipped and there is evidence the leaf-type-only restriction is actually limiting real code. Concretely, Phase A does not preclude Phase B: the AST shape (Pattern::Type { binding, class, .. }) and syntax are identical; Phase B only widens which class names are legal in that position. Flag for that future ADR: subclass-based exhaustiveness is inherently open-world (adding a new subclass elsewhere in the codebase, or via hot reload, changes what "exhaustive" means for existing matchExhaustive: sites using that hierarchy) — this is a materially harder soundness problem than Phase A's closed-union exhaustiveness and needs its own explicit decision, not an assumed extension of Phase A's approach.

The :: dual-semantics tension

This ADR's binding :: ClassName pattern performs a real runtime test — it is how the arm is selected. This is a different runtime guarantee than :: in a cast position (x :: T := y), which performs no runtime check at all — confirmed empirically this session: a field declared String | Nil concatenated via ++ with no guard compiled clean (zero diagnostics) and crashed at runtime with an opaque invalid argument badarg, because the ::-annotated binding never inserted a runtime tag check anywhere in codegen (spec_codegen.rs only emits Erlang -spec attributes, tooling metadata, never enforced at runtime). Reusing :: for both an unchecked static assertion (casts) and a checked runtime dispatch (patterns) is a readability win — same token, "this value has this type" in both places — but it does mean the runtime guarantee :: carries depends on which grammatical position it appears in. Disclosing this wart is not the same as justifying it, so here is the justification, not just the disclosure: position-dependent operator meaning is not unique to this design (* in C means dereference, multiplication, or pointer-declaration depending on position — the discipline that makes it workable is that a reader always knows which position they're in from surrounding grammar, not from the symbol alone). A rejected alternative made this concrete:

Alternative: a distinct pattern-only token (binding is ClassName) instead of reusing ::. This would make the runtime-checked/unchecked split visually obvious without requiring positional reasoning at all — a real argument, and the honest reason it's not chosen: introducing a second token for "this value has this type" directly undercuts this ADR's own central claim (reusing established syntax, not inventing new punctuation) and Principle #6's minimal-new-syntax spirit. Trading a disclosed, precedented class of ambiguity (position-dependent operator meaning) for a new token is not obviously a better trade, and ::'s existing ubiquity in every typed signature is exactly what makes the Newcomer steelman ("I'd guess it cold") hold — a fresh token would not have that property on day one.

Error examples

" Unknown class name — reuses the existing unresolved-class diagnostic (ADR 0100) "
raw match: [nil -> ""; x :: Sting -> x; _ -> ""]
" ⛔ Error: unknown class `Sting` (did you mean `String`?) "

" Phase A: non-leaf class in a type pattern "
shape match: [s :: Shape -> s area; _ -> 0]
" ⛔ Error: `Shape` has subclasses; type patterns are not yet supported for
"    non-leaf classes (ADR 0107 Phase B). Match on the concrete subclasses
"    instead, or use `isKindOf:` guard clauses. "

" matchExhaustive: on a closed Known|Nil union, one arm missing (Error, ADR 0106) "
raw :: String | Nil
raw matchExhaustive: [s :: String -> s size]
" ⛔ Error: non-exhaustive matchExhaustive: `Nil` is not handled
"    (residual type: `Nil`) "

" plain match:, same gap — advisory only (ADR 0102 §4 policy, unchanged) "
raw match: [s :: String -> s size]
"Warning: non-exhaustive match: `Nil` is not handled
"    (residual type: `Nil`) "

REPL example

> nil match: [nil -> 0; s :: String -> s size; _ -> -1]
0
> "hi" match: [nil -> 0; s :: String -> s size; _ -> -1]
2
> 42 match: [nil -> 0; s :: String -> s size; _ -> -1]
-1

Prior Art

LanguageApproachWhat we take / leave
Smalltalk (Pharo/Squeak/Newspeak)No first-class class-pattern matching; caseOf:otherwise: exists but is treated as anti-pattern. The idiom is isKindOf: guard chains — exactly Beamtalk today — or virtual dispatch (add a method). Message-passing purity historically argues dispatch should be polymorphic lookup, not a match.Take the caution, not the conclusion. The purist objection assumes you own the class to add a method to. Beamtalk's motivating cases are boundary values (parsed YAML/JSON, external API responses) where you cannot add a method to String/Nil/Dictionary for app-specific dispatch — the objection doesn't apply there. Scoping Phase A to exactly that boundary use (not general polymorphic dispatch) is the compromise.
Gleamcase matches variants of closed custom (sum) types, not an untyped value's class directly. Crossing a truly-dynamic boundary goes through gleam/dynamic/decode — decode/validate into a typed value first, then match exhaustively.Leave the "wrap first" purity (that's Constructor pattern territory, already how Result works) but take the boundary framing: a type pattern here is the deliberately non-exhaustive-by-default escape hatch for values that haven't been wrapped yet, not a replacement for wrapping. A boundary-coercion API (asStringOr:-style, decode-then-default) is a real alternative for the pure-fallback case — and was, in fact, already applied this session to seven other call sites in the same file that motivated this ADR (Config#coerceString:default:, #coerceStringOrNil:, #coerceStringList:default:) — but it only replaces a default value, not branch-specific logic. Both of this ADR's two motivating call sites need different logic per case (env-var expansion vs. home-directory expansion vs. default; List iteration vs. empty), which a coercion combinator can't express — that's exactly why those two sites needed guard-clause chains instead of a coercion call, and exactly the gap this ADR closes.
Elixir/Erlang (compile target)case x do b when is_binary(b) -> ...; nil -> ...; n when is_integer(n) -> ... end. Type tests are guard-safe BIFs. Idiomatic, this is the direct lowering target. No exhaustiveness over type coverage (only over unmatched literals).Adopt directly as the codegen strategy — Phase A's leaf-type patterns lower to exactly this shape. Confirms there is no "free" exhaustiveness to inherit from the runtime; Beamtalk's exhaustiveness (where it applies) has to come from the type checker's own closed-union tracking, same as BT-2745 already does for symbol unions.
Swift / Kotlin / RustSwift: case let s as String: — explicit named binding. Kotlin: is String -> x.length — implicit smart-cast (flow narrowing, no new name); known to break on mutable/cross-module properties. Rust: matches enum variants, not runtime classes; exhaustive only because enums are closed.Take Swift's explicit-binding shape (path :: String, a real new name) over Kotlin's smart-cast — a named binding sidesteps the mutability/stability traps smart-casting has in Kotlin, at the cost of one more identifier per arm. Confirms the cross-cutting lesson: exhaustiveness only comes from closed unions; an open class hierarchy (Phase B) must require a wildcard, never claim completeness.

User Impact

Steelman Analysis

Chosen: binding :: ClassName type pattern + nil pattern, Phase A/B split

Rejected B: widen when: guards to allow arbitrary message sends (isKindOf:, isNil), no new Pattern variant

raw match: [
  x when: [x isNil] -> defaultRoot;
  x when: [x isKindOf: String] -> self expandHome: x;
  _ -> defaultRoot
]

Rejected C: no new match: syntax — add exhaustiveness lint tooling for the existing guard-clause idiom

Rejected D: full isKindOf:-semantics class pattern (subclass-polymorphic), single phase, no split

Tension points

Smalltalk purists are the one cohort not fully behind the chosen option — genuinely split between B (zero new syntax, purest message-passing) and C (truest to the historical anti-isKindOf:-dispatch tradition); neither loves a new Pattern variant as much as the newcomer/language-designer/BEAM-veteran cohorts do. Operators and BEAM veterans align firmly against D for the same reason: the bounded, already-proven-safe runtime surface of Phase A is a better trade than full generality shipped untested. No cohort's strongest argument for D beats their argument for the chosen option — it is the consensus-weakest alternative, specifically because of fresh, concrete evidence (this session's own bug hunt) about what "ship the general, untested version first" costs in this exact subsystem.

Alternatives Considered

See Steelman Analysis above — guard-widening (B), lint-only (C), and single-phase full generality (D) were all considered and rejected in favour of the type-pattern-with-Phase-A/B-split, for the reasons given there. A distinct pattern-only token (binding is ClassName) instead of reusing :: was also considered — see "The :: dual-semantics tension" in the Decision section — and rejected in favour of reusing established syntax.

Consequences

Positive

Negative

Neutral

Implementation

No new runtime/BEAM primitives are introduced — every codegen strategy below emits ordinary Erlang BIFs and map operations that already exist in generated code today. Recommended build order starts with the smaller, zero-ambiguity piece before the two-strategy Type pattern: land Pattern::Nil end-to-end first (parser → bindings → codegen, reusing the existing atom-literal path verbatim) as the "does the AST/pipeline wiring for a new pattern kind work" proof, then add Pattern::Type with its BIF-test/map-tag-check dispatch and the narrowing/exhaustiveness extensions on top of a wiring path already known to work.

References