Pattern Matching
match e is followed by arms, one per line: | pattern => value. Arms are tried from the top. Every arm has the same type, which is the type of the whole match.
Patterns and guards
fn label(s: Shape) -> Str
= match s
| Circle{r} if r > 10.0 => "big circle"
| Circle => "circle"
| Rect{w, h} if w == h => "square"
| Rect{w: width} => "rect {width} wide"
| Dot => "dot"
Circle{r}binds the fieldr.Rect{w: width}binds the fieldwto the namewidth. Fields you don’t name are ignored, and a bareCircleignores all of them.ifafter a pattern is a guard. The arm only matches when the guard is true._matches anything and binds nothing.
Tuples, literals and options
Matching on a tuple checks several values at once, and literals match themselves. some(p), none, ok(p) and err(p) take apart Opt and Res:
fn fizz(n: Int) -> Str
= match (n % 3, n % 5)
| (0, 0) => "FizzBuzz"
| (0, _) => "Fizz"
| (_, 0) => "Buzz"
| _ => n.str
fn parse_port(s: Str) -> Str
= match s.to_int
| some(p) if p > 0 and p < 65536 => "port {p}"
| some(p) => "{p} is out of range"
| none => "not a number"
Or-patterns
p1 | p2 matches when either alternative does, so cases that share an arm are written once. Alternatives nest anywhere a pattern goes, some(0 | 1) or (Red | Blue, _):
fn corners(s: Shape) -> Int
= match s
| Circle | Dot => 0
| Rect => 4
fn weekend(day: Str) -> Bool
= match day.lower
| "sat" | "sun" => true
| _ => false
Every alternative has to bind the same names with the same types: | Circle{r} | Rect{w} => r is E_PATTERN_OR_BINDS, because r would be unbound when a Rect matched.
Lists
[], [x] and [x, y] match lists of exactly that length. .. matches any number of elements, and ..rest binds them as a list, at the start, the end or in the middle: [x, ..rest], [..init, last], [first, .., last]. The elements are patterns themselves, so literals, tuples and constructors work inside:
fn total(xs: List[Int]) -> Int
= match xs
| [] => 0
| [x, ..rest] => x + total(rest)
fn describe(xs: List[Str]) -> Str
= match xs
| [] => "nothing"
| [one] => one
| [first, .., last] => "{first} to {last}"
fn route(parts: List[Str]) -> Str
= match parts
| ["users", id] => "user {id}"
| ["users", id, "posts", ..rest] => "{rest.len} posts of {id}"
| [_, ..] | [] => "not found"
[] and [x, ..rest] together cover every list. In native code rest is a view of the same list, so walking a list this way copies nothing.
One case: if e is p
When only one case matters, if e is p then a else b tests it without a full match. The names the pattern binds are in scope in the then branch, and and after the pattern adds a condition that can use them:
fn port_or(s: Str, d: Int) -> Int
= if s.to_int is some(p) and p > 0 then p else d
fn radius(s: Shape) -> F64
= if s is Circle{r} then r else 0.0
else can be left out when the then branch is Unit, and tests chain with else if e2 is q then. Code written as if let some(p) = e then is stored as if e is some(p) then.
e is p anywhere
e is p is also a plain Bool, so it works wherever a condition does: in filter and any, in a test, on the right of =. Several tests join with and, and an if sees the names all of them bind:
fn circles(shapes: List[Shape]) -> Int
= shapes.filter(_ is Circle).len
fn has_pair(xs: List[Int]) -> Bool
= xs is [_, _, ..]
fn both_round(a: Shape, b: Shape) -> F64
= if a is Circle{r} and b is Circle{r: r2} then r + r2 else 0.0
A name a test binds is only usable in the and ... after it, and in the then branch when the test is an if condition. ok = r is ok(v) leaves v with nothing to use it, so it’s the error E_IS_BINDS, with the hint to write ok(_). is takes everything before it as the value, so a and b is p is E_PARSE_IS: put the test first (b is p and a) or parenthesize it (a and (b is p)). A test that always matches gets the warning W_ARM_UNREACHABLE, and one that can’t match the value written there, like none is some(_), gets W_NEVER_MATCHES.
Loops: while e is p
while e is p repeats its body as long as e matches p, with the names p binds in scope in the body. e is evaluated and matched again before each pass, and the loop stops at the first value that doesn’t match. Tests join with and as they do after if, so while q.pop_front is some((x, rest)) and x > 0 stops at the end of the list or at the first element that isn’t positive. Like any while, it performs div and its body is Unit; a pattern that always matches gets W_ARM_UNREACHABLE.
Guard clauses: p = e else b
A binding can take a pattern that might not match, if it says what happens when it doesn’t. The rest of the block runs with the names bound; when the pattern doesn’t match, the block stops there and its value is b:
type ArgErr = Missing{name: Str}
fn first_port(args: List[Str]) -> Int
= do
[first, ..] = args else 80
some(p) = first.to_int else 80
p
fn need(opts: Map[Str, Str], name: Str) -> Str ! fail[ArgErr]
= do
some(v) = opts.get(name) else raise Missing{name}
v.trim
In a function body b is what the function returns, so else 0, else return err("bad") and else raise NotFound{id} all work. In a loop body, else () skips to the next element, and in a lambda’s do block b is the lambda’s value. A pattern that can’t fail, like (a, b) or Point{x, y}, needs no else; one that can, without else, is E_REFUTABLE_LET. Rust’s let some(x) = e else { ... } and Swift’s guard let x = e else { ... } are stored as some(x) = e else ....
Exhaustiveness
A match has to cover every case. If it doesn’t, the program doesn’t compile, and the error names the missing case:
type Shape = Circle{r: F64} | Rect{w: F64, h: F64} | Dot
fn area(s: Shape) -> F64
= match s
| Circle{r} => 3.14 * r * r
| Rect{w, h} => w * h
type Zone = Local | Remote
rule fee(kg: Int, zone: Zone) -> Int
| kg <= 5, Local => 5
| kg > 5, _ => 12
fn second(xs: List[Int]) -> Int
= match xs
| [_, x, ..] => x
| [] => 0
| [_, _] => -1
$ sspur check missing.ssp
missing.ssp:4:3 E_NONEXHAUSTIVE match does not cover: Dot
hint: add | Dot => ?
missing.ssp:11:3 E_RULE_GAP no row matches: kg = 4, zone = Remote; kg = 5, zone = Remote
hint: add a row such as | _, _ => ?
missing.ssp:15:3 E_NONEXHAUSTIVE match does not cover: [_]
hint: add | [_] => ?
missing.ssp:18:15 warning W_ARM_UNREACHABLE arm 3 '| [_, _]' can never match: the arms above cover it
hint: remove it, or move it above the arm that covers it
This is why adding a variant to a sum type is safe: the checker lists every match that needs a new arm. The check looks inside nested patterns and lists too, so the missing case can be some(none) or [_], and an arm that the arms above already cover gets the warning W_ARM_UNREACHABLE.
One thing to watch: a match nested inside an arm takes the arms below it, up to the first one indented less than its own. Indent the inner arms deeper than the outer ones; arms at the same column all belong to the inner match.
Decision tables
Business rules often look like a table: a few inputs, many cases, first match wins. rule writes them that way. Each row has one cell per parameter, and a cell is _, a pattern, or a condition on that parameter:
type Zone = Local | Remote
rule fee(kg: Int, zone: Zone, prime: Bool) -> Int
| kg <= 1, _, true => 0
| kg <= 5, Local, _ => 5
| _, Remote, _ => 12
| _, _, _ => 8
The checker treats a rule like a match. It reports inputs that match no row (E_RULE_GAP, with examples, as in the second error above) and rows that can never fire because earlier rows cover them (E_RULE_SHADOWED).
$ sspur test matching.ssp
9 passed, 0 failed
Next: Effects and Errors.