Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 field r. Rect{w: width} binds the field w to the name width. Fields you don’t name are ignored, and a bare Circle ignores all of them.
  • if after 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.