The match expression in BAML provides exhaustive, type-safe pattern matching over union types, enums, and literal values. It enables declarative handling of different data shapes while ensuring at compile time that all cases are covered.
Motivation
LLM responses in BAML frequently use union types and nullable fields. Currently, handling these requires verbose if-else chains that are error-prone and don't guarantee exhaustiveness:
function Process(result: LlmResult) -> string {
if (result == null) {
return "No result"
}
if (result instanceof Success) {
if (result.score >= 0.9) {
return "High confidence"
}
return "Low confidence"
}
if (result instanceof Failure) {
return "Failed: " + result.reason
}
// What if we forget a case? No compiler help.
}
Pattern matching solves this with: 1. Exhaustiveness checking — the compiler ensures all cases are handled 2. Cleaner syntax — flat, declarative case enumeration 3. Type narrowing — bound variables have their type narrowed within each arm
Core Design Decisions
Decision 1: Type Patterns Require Explicit Binding
Problem: In a bare pattern like match(x) { Type1 => ... }, it's ambiguous whether Type1 is a type name or a variable/value.
Solution: Type patterns always use the name: TypeExpr syntax:
match (x) {
s: Success => "got success: " + s.data // s is bound, type is Success
_: Failure => "got failure" // _ is bound but discarded
value1 => "literal match" // value1 is a literal or catch-all
}
This makes parsing unambiguous and aligns with TypeScript's type annotation syntax.
Decision 2: _ is a Binding, Not a Special Keyword
The underscore _ is a valid binding name. Like any other binding, it captures the matched value. However, the value is dropped later in the pipeline (not accessible in the arm body).
match (result) {
_: Success => "success" // _ bound to Success, but dropped
other => "other: " + other // other bound and usable
}
Important: There is no special default keyword. Any untyped binding (including _ or any identifier) acts as a catch-all because it matches any value.
Decision 3: Catch-All via Untyped Binding
A pattern without : TypeExpr matches anything and binds the scrutinee:
match (x) {
_: int => "integer"
_: string => "string"
other => "something else: " + other // catch-all, binds to 'other'
}
// Or using _ as catch-all (value discarded):
match (x) {
_: int => "integer"
_ => "not an integer" // catch-all, value discarded
}
Decision 4: Full Type Expression Generality
The type expression after : supports full generality — unions, type aliases, parenthesized groups:
// All equivalent:
x: int | bool
x: (int | bool)
x: (int) | (bool)
// Complex unions:
result: Success | Failure
code: 200 | 201 | 204
cmd: "start" | "stop"
Decision 5: Union Types Don't Collapse
When binding a union pattern, the bound variable retains the exact union type, not a collapsed/widened type:
class Success { data string }
class Failure { reason string }
class Pending { eta int }
match (result) {
x: Success | Failure => {
// x has type `Success | Failure`, NOT some supertype
handle(x)
}
_: Pending => "pending"
}
This preserves precision: you know exactly which types x could be.
Decision 6: Exhaustiveness via Untyped Binding
Exhaustiveness is required. An untyped binding (catch-all) satisfies exhaustiveness for all remaining cases:
type Result = Success | Failure | null
// Exhaustive via explicit patterns:
match (result) {
_: Success => "ok"
_: Failure => "error"
null => "nothing"
}
// Exhaustive via catch-all:
match (result) {
_: Success => "ok"
_ => "not success" // covers Failure and null
}
// NOT exhaustive — compile error:
match (result) {
_: Success => "ok"
_: Failure => "error"
// Error: 'null' not handled
}
Future enhancement: Warn when a catch-all covers multiple cases, to encourage explicit handling.
Syntax Specification
Grammar
match_expr := 'match' '(' expr ')' '{' match_arm+ '}'
match_arm := pattern guard? '=>' arm_body
pattern := binding_pattern | literal_pattern | union_pattern
binding_pattern := IDENT (':' type_expr)?
literal_pattern := 'null' | 'true' | 'false' | INTEGER | FLOAT | STRING
union_pattern := (literal_pattern | enum_variant) ('|' (literal_pattern | enum_variant))*
enum_variant := IDENT '.' IDENT
guard := 'if' expr
arm_body := expr | block_expr
type_expr := ... (existing type expression grammar)
Pattern Forms
| Pattern | Matches | Binding | Example |
|---|---|---|---|
name |
Anything | name bound to scrutinee |
other => use(other) |
_ |
Anything | Discarded | _ => "fallback" |
name: Type |
Values of type T |
name bound (narrowed) |
s: Success => s.data |
_: Type |
Values of type T |
Discarded | _: Failure => "failed" |
null |
null value |
None | null => "nothing" |
true / false |
Boolean literal | None | true => "yes" |
42 / 3.14 |
Numeric literal | None | 200 => "OK" |
"foo" |
String literal | None | "start" => "starting" |
Enum.Variant |
Enum variant (value equality) | None | Status.Active => "active" |
A \| B |
Union of literals/enum variants | None | Status.Active \| Status.Pending => ... |
x: A \| B |
Union of types (not values) | x bound |
x: Success \| Failure => ... |
Note: The
Typeafter:must be an actual type (class, primitive, type alias), not an enum variant. Enum variants are values and must be matched directly without:binding.
Precedence
The : in name: TypeExpr binds tighter than |:
x: int | bool // parsed as x: (int | bool)
x: (int | bool) // same as above
x: (int) | (bool) // same as above
Detailed Examples
Example 1: Basic Union Discrimination
class Success { data string, score float }
class Failure { reason string, code int }
type Result = Success | Failure | null
function Process(result: Result) -> string {
return match (result) {
null => "No result"
s: Success => "Got: " + s.data
f: Failure => "Error: " + f.reason
}
}
Example 2: Guards for Conditional Matching
Guards add conditions that must be true for the arm to match:
function Classify(result: Result) -> string {
return match (result) {
null => "none"
s: Success if s.score >= 0.9 => "excellent: " + s.data
s: Success if s.score >= 0.7 => "good: " + s.data
s: Success => "marginal: " + s.data
f: Failure if f.code >= 500 => "server error: " + f.reason
f: Failure => "client error: " + f.reason
}
}
Important: Guards do not contribute to exhaustiveness. A guarded pattern s: Success if cond does not cover all Success values. You must have an unguarded fallback.
Scope note: This is a simplification for the current proposal. Future versions may support smarter exhaustiveness analysis that recognizes complementary guards (e.g.,
if x > 0andif x <= 0) as covering all cases.
Example 3: Enum Matching
enum Status { Active, Inactive, Pending, Archived }
function Describe(s: Status) -> string {
return match (s) {
Status.Active => "User is active"
Status.Inactive => "User is inactive"
Status.Pending => "Awaiting approval"
Status.Archived => "User archived"
}
}
If you later add Status.Deleted, the compiler will error on this match — forcing you to handle the new case.
Example 4: Literal Unions
type HttpSuccess = 200 | 201 | 204
type HttpError = 400 | 404 | 500
function DescribeStatus(code: int) -> string {
return match (code) {
200 | 201 => "Success with content"
204 => "Success, no content"
400 | 404 => "Client error"
500 => "Server error"
_ => "Unknown status: " + code
}
}
Example 5: Variant Unions
enum Status { Active, Inactive, Pending }
function IsActionable(s: Status) -> bool {
return match (s) {
Status.Active | Status.Pending => true
Status.Inactive => false
}
}
Example 6: Type Unions with Binding
type Primitive = string | int | bool
type Complex = User | Image
type Any = Primitive | Complex | null
function Categorize(val: Any) -> string {
return match (val) {
null => "nothing"
p: Primitive => "primitive value" // p has type string | int | bool
c: Complex => "complex object" // c has type User | Image
}
}
Example 7: Nested Match
class Request { auth: ApiKey | OAuth | null, endpoint: string }
function Authorize(req: Request) -> string {
return match (req.auth) {
null => "No auth for " + req.endpoint
a: ApiKey => match (a.key) {
k: string if k.startsWith("prod_") => "Production key"
_ => "Dev key"
}
o: OAuth if o.expires > now() => "Valid OAuth"
_: OAuth => "Expired OAuth"
}
}
Example 8: Block Bodies
When an arm needs multiple statements, use a block. The last expression is the result:
match (status) {
_: Error => {
log("Error occurred")
metrics.increment("errors")
"Failed" // return value
}
_ => "OK"
}
Exhaustiveness Checking
The compiler performs exhaustiveness analysis to ensure all possible values are handled.
Rules
- All cases must be covered — either explicitly or via catch-all
- Guarded arms don't guarantee coverage —
s: T if condcovers only a subset ofT - Catch-all covers remaining cases — an untyped binding (
_orname) covers everything not yet matched - Order matters — first matching arm wins; unreachable arms are a compile error
Examples
type T = A | B | C
// OK: all explicit
match (x) {
_: A => "a"
_: B => "b"
_: C => "c"
}
// OK: catch-all covers B and C
match (x) {
_: A => "a"
_ => "not a"
}
// ERROR: C not covered
match (x) {
_: A => "a"
_: B => "b"
}
// ERROR: unreachable arm (B already covered by catch-all)
match (x) {
_: A => "a"
_ => "other"
_: B => "b" // unreachable!
}
Semantics
Evaluation Order
- Evaluate the scrutinee expression once
- Test arms top-to-bottom
- For each arm:
- Check if pattern matches
- If pattern has a guard, evaluate guard
- If both match, bind variables and evaluate arm body
- First matching arm's body is the result
Type Narrowing
Within a matched arm, bound variables have their type narrowed:
type T = string | int | null
match (x) {
s: string => s.length() // s is string, not string | int | null
n: int => n + 1 // n is int
null => 0
}
Binding Scope
- Bound variables are in scope in the guard and arm body
- Bound variables do not leak outside the arm
- The same binding name can be reused across arms
match (x) {
a: A => use(a) // a is A
a: B => use(a) // a is B (different a, shadows previous)
}
// a is not accessible here
Comparison to Other Languages
| Feature | Rust | Python 3.10+ | TypeScript | BAML |
|---|---|---|---|---|
| Syntax | match x { ... } |
match x: case ... |
N/A | match (x) { ... } |
| Type patterns | Some(x) |
case Some(x) |
N/A | x: Type |
| Binding | x @ pattern |
case x |
N/A | x: Type or x |
| Guards | if cond |
if cond |
N/A | if cond |
| Exhaustiveness | Enforced | Optional | N/A | Enforced |
| Literal unions | 1 \| 2 \| 3 |
case 1 \| 2 \| 3 |
N/A | 1 \| 2 \| 3 |
Key BAML choices:
- : for type binding (familiar to TS/JS developers)
- Parentheses around scrutinee (familiar to C-family)
- => for arm bodies (familiar to JS arrow functions)
- No special default keyword; catch-all is just an untyped binding
What's NOT in This Proposal
The following features are explicitly deferred for future consideration:
Destructuring (Phase 3 - Future)
// NOT YET SUPPORTED:
match (user) {
User { name: "Admin" } => "admin"
User { name, age } => name + " is " + age
}
Destructuring introduces complexity around:
- Nullable field handling ({ field } vs { field? })
- Literal vs variable disambiguation in field patterns
- Nesting depth limits
Multiple Patterns Per Arm
// NOT SUPPORTED:
match (x) {
_: A | _: B => "a or b" // can't have multiple binding patterns
}
// INSTEAD, use type union:
match (x) {
_: A | B => "a or b" // A | B is a type expression
}
@ Binding (Bind Whole + Destructure)
Implementation Notes
Parser Changes
Add parse_match_expr to the parser, producing:
- MATCH_EXPR node containing:
- Scrutinee expression
- One or more MATCH_ARM nodes
Each MATCH_ARM contains:
- Pattern (binding, literal, or union)
- Optional guard expression
- Arm body expression
Type Checker Changes
- Pattern type inference — determine what type each pattern matches
- Exhaustiveness analysis — compute coverage, error on gaps
- Unreachable arm detection — error on arms that can never match
- Type narrowing — narrow bound variable types within arms
Codegen Changes
Desugar match to equivalent if-else chain with instanceof checks:
// Source:
match (x) {
s: Success if s.score > 0.9 => "high"
s: Success => "low"
_: Failure => "failed"
}
// Desugared (conceptually):
{
let $scrut = x
if ($scrut instanceof Success) {
let s = $scrut
if (s.score > 0.9) {
"high"
} else {
"low"
}
} else if ($scrut instanceof Failure) {
"failed"
} else {
// unreachable if exhaustive
}
}
Key Implementation Caveats
These are subtle points that implementers must handle correctly:
1. _ is NOT a Keyword
Unlike Rust where _ is a special pattern, in BAML _ is just an identifier that happens to be dropped. The lexer should emit it as a WORD token, not a special token. The "drop" behavior is handled later in the pipeline (name resolution or codegen), not in parsing.
// These are parsed identically at the syntax level:
_ => "fallback"
other => "fallback"
// The difference is semantic: _ is dropped, other is usable
2. Type Expressions vs Value Expressions
After :, we parse a type expression, not a value expression. This means:
- s: Success — Success is resolved as a type (class name)
- s: 200 | 201 — 200 and 201 are literal types
- s: Success | Failure — union of class types
Without the :, we have value patterns:
- 200 — matches the integer value 200
- Status.Active — matches the enum variant (via value equality)
- other — binds to any value
3. Enum Variants Are Values, Not Types
Important distinction: Enum variants like Status.Active are values, not types. You match them directly as value patterns:
// CORRECT: Enum variant as value pattern (no binding needed)
Status.Active => "active"
Status.Active | Status.Pending => "actionable"
// INCORRECT: Cannot use enum variant after `:` — it's not a type!
// x: Status.Active => ... // ERROR: Status.Active is a value, not a type
To match an enum with binding, match the entire enum type and use guards:
enum Status { Active, Inactive, Pending }
match (s) {
// Match specific variants as values (no binding):
Status.Active => "active"
Status.Inactive => "inactive"
Status.Pending => "pending"
}
// Or match the enum type with binding and use guards:
match (s) {
x: Status if x == Status.Active => "active: " + x
_: Status => "other status"
}
4. Guards Don't Affect Exhaustiveness
This is critical for the exhaustiveness checker:
Even though s: Success appears, the guard makes it partial. The checker must track "guarded coverage" separately from "total coverage."
5. Scrutinee Evaluation
The scrutinee must be evaluated exactly once and stored:
match (expensiveCall()) {
_: A => ...
_: B => ...
}
// Must compile to:
let $scrut = expensiveCall()
if ($scrut instanceof A) { ... }
else if ($scrut instanceof B) { ... }
// NOT:
if (expensiveCall() instanceof A) { ... } // Wrong: multiple calls!
6. Union Type Precision
When x: A | B matches, x has type A | B, not a supertype:
class Success { data string }
class Failure { reason string }
class Pending { eta int }
match (result) {
x: Success | Failure => {
// x has type: Success | Failure
// NOT some broader type
}
_: Pending => "pending"
}
This requires the type checker to construct the exact union type from the pattern.
7. First-Match Semantics and Warnings
Order matters. The compiler should error on unreachable arms:
But overlapping typed patterns are fine (first wins):
Open Questions (Resolved)
These questions from earlier drafts have been resolved:
| Question | Resolution |
|---|---|
default vs _ for catch-all? |
No special keyword; any untyped binding is catch-all |
Guard keyword: if or when? |
if — familiar to all |
Should \| allow multiple patterns? |
No; \| is always a type/value union within one pattern |
| Require parens in type unions? | No; precedence is clear (x: A \| B = x: (A \| B)) |