Chapter 7 Pattern Matching
Source:match appeared briefly in chapter 3. This chapter covers the full pattern grammar, the mechanics of exhaustiveness checking, and two features that make Silo's matcher pull more weight than most: Never-elimination isomorphisms and downcasting on existential types.
The shape of :match
:match pops the top value and runs the first arm whose pattern matches. Each arm is | pattern => body. The arms run in source order; the compiler checks that the arms together cover every value the scrutinee can take.
A worked example:
:union Expr
| Lit Int
| Add Expr Expr
| Mul Expr Expr
:end
:fn eval ( Expr -> Int )
:match
| Lit n =>
n
| Add a b =>
a eval b eval +
| Mul a b =>
a eval b eval *
:end
:end
Each arm pops the Expr, binds the payload to names, runs its body, and leaves one Int on the stack. The compiler checks that every variant is covered and that every arm produces the same stack shape.
Pattern forms
The grammar for what appears between | and =>:
| Form | Example | Matches |
|---|---|---|
| Union variant with payload | Lit n |
Lit variants, binding the payload |
| Named-field destructure | Rect .width w .height h |
Union variants with named fields, bound by name |
| Record destructure | Point .x px .y py |
A record's fields, bound by name |
| Integer literal | 42 |
That specific integer |
| String literal | "hello" |
That specific string |
| Boolean literal | true |
That specific Bool |
| Range | 0..10 |
Any integer in the half-open range |
| Symbol literal | 'ok |
That specific symbol |
| Type name (existential) | Int |
Downcasts an existential to a type |
| Wildcard | _ |
Any value, ignoring it |
Patterns nest. You can destructure a union variant whose payload is another union, and so on as deep as you like:
:match
| Some ( Ok n ) =>
n "got {}" format
| Some ( Err e ) =>
e "error: {}" format
| None =>
"nothing"
:end
Exhaustiveness
The compiler checks that every possible value of the scrutinee is covered. If any case could reach :end without matching, that's a compile error — not a runtime one.
# This does NOT compile:
:fn flaky ( ( Option Int ) -> Int )
:match
| Some n => n
# Missing | None => ...
:end
:end
The error points at the missing arm. To suppress the check deliberately — for example, when you've proven elsewhere that a case can't happen — use _:
:match
| Some n => n
| _ => 0
:end
A _ arm always matches, so combining it with any other patterns is exhaustive by construction.
:if-let and :let revisited
The single-pattern variants from chapter 3 desugar into a two-arm :match. Exhaustiveness applies to both:
:if-let PAT => body :else else-body :enddesugars to:match | PAT => body | _ => else-body :end. The_arm covers everythingPATdoesn't, so the whole thing is exhaustive regardless of which variantPATpicks.:let PAT :else else-body :enddoes the same, but the:elsebranch must diverge — the compiler checks that it cannot fall through, because the rest of the enclosing word depends onPAT's bindings having been produced.
Never in return types
Chapter 6 introduced Never as the bottom type. The pattern matcher interacts with Never in two important ways.
Uninhabitable arms may be omitted. When a union variant's payload contains Never, that variant has no values, and the compiler lets you leave out its arm:
:fn safe-parse ( Str -> ( Result Int Never ) ) .. . :end
"42" safe-parse :match
| Ok n =>
n # only Ok arm needed — exhaustive
:end
(Result Int Never) has only Ok values at runtime; Err would need a Never value, which can't exist. The compiler recognises this and drops the exhaustiveness requirement on the Err arm.
Safe unwrap when failure is impossible. The same reasoning lets .unwrap avoid declaring +Panic:
"42" safe-parse .unwrap # ( (Result Int Never) -> Int )
The implementation of .unwrap panics on Err, but on a value of type (Result Int Never) there are no Err values — so the panic is unreachable, and the effect row can be empty.
Type-level isomorphisms via =>
When you see a return type like (Result Int Never) pop up a lot, a function's signature can declare the isomorphism explicitly with =>:
:fn safe-parse2 ( Str -> ( Result Int Never ) => Int ) .. . :end
Reading the signature: the word returns (Result Int Never), which is isomorphic to Int. Callers can treat the result as either shape interchangeably:
"42" safe-parse2 1 + # coerced to Int automatically
"42" safe-parse2 .unwrap # still works as Result too
The recognised isomorphisms are:
| Type | Isomorphic to |
|---|---|
(Result a Never) |
a |
(Result Never b) |
b |
(Option Never) |
Unit |
Each corresponds to a union variant being uninhabitable. These are not magic; the compiler verifies each case by checking that the excluded variant's payload is inhabited only by Never.
Downcasting existential types
A trait name in a type position is an existential type: "some value implementing this trait, concrete type unknown". Pattern matching on an existential lets you downcast — check what concrete type is inside and bind accordingly:
:fn describe ( Display -> Str )
:match
| Int => "{}" format " (integer)" +
| Str => "\"" swap + "\" (string)" +
| _ => "{}" format " (other)" +
:end
:end
The arm patterns here are type names, not values. | Int => says "if the existential's concrete type is Int, run this arm". The compiler checks that each type you mention actually implements the existential's trait; Int => on a (Display …) is fine because Int implements Display, but Widget => would fail if Widget doesn't.
Downcasting is useful exactly where you'd use match on a trait object in Rust or a type-case in a language with explicit runtime types. The wildcard _ arm handles everything not enumerated — including trait-implementors the compiler hasn't seen yet in other modules.
When to reach for :match
Most code you write won't need :match. Silo's other control forms — :if, :if-let, :let, :try — are shorter and clearer when they fit. Reach for a full :match when:
- You have more than two cases to distinguish.
- The cases have structurally different shapes (payload destructuring).
- You genuinely need exhaustiveness on a union or enum with many variants.
- You're downcasting an existential.
The Silo idiom is to let the simplest form that still reads well carry the weight.
Key points
:matcharms are pattern → body, checked for exhaustiveness.- The pattern grammar covers variants, records, literals, ranges, symbols, type names (for existential downcast), and
_. Patterns nest. :if-letand:letare two-arm:matchs in disguise; the exhaustiveness story carries through.Neverin a union variant's payload makes the variant uninhabitable. The compiler lets you omit its arm and lets operations like.unwrapskip+Panic.=>in a signature declares aNever-elimination isomorphism. Callers can use either the original type or the isomorphic one.- Pattern matching on a trait-typed existential downcasts to concrete types, with
_catching everything not listed.
Next: traits — the system you've been seeing referenced throughout.