Pattern Matching

Pattern matching is the only branching construct. No if, no ternary, no switch. Every branch uses match, and the compiler checks exhaustiveness.

Match with Scrutinee

fn describe(shape) {
  match shape {
    Circle(r) -> "circle with radius {r}"
    Rect(w, h) -> "rect {w}x{h}"
  }
}

Each arm starts on its own line; there is no separator between arms. A match with one arm may be written on one line: match pair { (a, b) -> a + b }.

An arm’s body is one expression; statements take a block, p -> { ... }, whose last expression is the arm’s value. One expression needs no braces, and silt fmt takes them away: 0 -> { "done" } becomes 0 -> "done". (It leaves them where taking them away would move a comment to the next arm or change how the next arm is read, and inside a string it keeps as written.)

Match without Scrutinee (Boolean Dispatch)

Omit the scrutinee for boolean conditions:

fn classify(n) {
  match {
    n == 0 -> "zero"
    n > 0 -> "positive"
    _ -> "negative"
  }
}

The arms are tried in order and the first true condition wins. Such a match must end with a _ -> ... arm, the value when no condition is true; leaving it out is a type error.

Literal Patterns

Including negative numbers:

match n {
  0 -> "zero"
  1 -> "one"
  -1 -> "negative one"
  _ -> "other"
}

Works for Int, Float, String, and Bool.

Constructor and Nested Patterns

fn handle(result) {
  match result {
    Ok(Some(value)) -> use(value)
    Ok(None) -> handle_empty()
    Err(e) -> handle_error(e)
  }
}

Qualified Patterns

Constructors and record types from another module are named through the module (or its alias): m.Variant, or m.Enum.Variant, which always works, even when two enums of the module share a variant name. Inside the declaring module, Enum.Variant works too:

-- noexec (multi-file: shapes.silt declares `pub type Shape { Circle(Float), Rect(Float, Float) }`)
import shapes

fn describe(s) {
  match s {
    shapes.Circle(r) -> "circle {r}"
    shapes.Shape.Rect(w, h) -> "rect {w}x{h}"
  }
}

Qualified patterns work in every pattern position — match arms, when let, let, or-patterns, and nested sub-patterns — and the qualifier picks the right type when two imported modules export the same name (a.Pt { x, .. } vs b.Pt { label, .. }). Naming the wrong module or enum is a type error.

Tuple Patterns

fn fizzbuzz(n) {
  match (n % 3, n % 5) {
    (0, 0) -> "FizzBuzz"
    (0, _) -> "Fizz"
    (_, 0) -> "Buzz"
    _ -> "{n}"
  }
}

Record Patterns

Use .. to ignore remaining fields:

fn greet(user) {
  match user {
    User { name, active: true, .. } -> "hello {name}"
    User { name, .. } -> "{name} is inactive"
  }
}

List Patterns

Three forms: empty [], exact length [a, b, c], head/tail [h, ..t]:

fn sum(xs) {
  match xs {
    [] -> 0
    [head, ..tail] -> head + sum(tail)
  }
}

Nested patterns work in list elements: [Some(a), Some(b), ..rest]. The .. syntax matches the record rest pattern (User { name, .. }).

Map Patterns

match config {
  #{ "name": name, "greeting": greeting } -> "{greeting}, {name}!"
  #{ "name": name } -> "hello, {name}!"
  _ -> "hello, stranger!"
}

Or-Patterns

Match multiple alternatives in a single arm with |:

match n {
  0 | 1 -> "small"
  2 | 3 -> "medium"
  _ -> "large"
}

A long list of alternatives may continue on the next line, which then starts with its |:

match day {
  "Saturday"
  | "Sunday" -> "weekend"
  _ -> "weekday"
}

All alternatives must bind the same variables (and, as in any match, all alternatives must have the same type — you can’t mix Some(x) with Ok(x) in one or-pattern):

-- OK: both alternatives bind `x`
fn unwrap_either(r: Result(Int, Int)) -> Int {
  match r {
    Ok(x) | Err(x) -> x
  }
}

fn main() {
  println("{unwrap_either(Ok(7))}") -- 7
}
open in playground
-- ERROR: first alternative binds x, second binds y
Ok(x) | Err(y) -> ...
-- compile error: or-pattern alternatives must bind the same variables

Guards

match n {
  0 -> "zero"
  x when x > 0 -> "positive"
  _ -> "negative"
}

Guard expressions are checked after the pattern matches. Pattern bindings are available in the guard.

Range Patterns

Inclusive numeric ranges:

match score {
  90..100 -> "A"
  80..89 -> "B"
  70..79 -> "C"
  _ -> "F"
}

Float ranges work too: 0.0..1.0 matches value >= 0.0 && value <= 1.0.

Pin Operator (^)

Matches against the value of an existing variable instead of creating a new binding:

let expected = 42
match input {
  ^expected -> "got the expected value"
  other -> "got {other} instead"
}

The variable is one from outside the pattern. A name the pattern binds itself is not it: (x, ^x) compares the second element with an x bound before the match, and is an error when there is none.

Works in any pattern position. Common with channel.select:

match channel.select([channel.Recv(ch1), channel.Recv(ch2)]) {
  (^ch1, channel.Message(msg)) -> handle_first(msg)
  (^ch2, channel.Message(msg)) -> handle_second(msg)
  _ -> panic("unexpected")
}

when let/else for Inline Assertions

Asserts a pattern match and binds on success, or diverges on failure:

fn process(input) {
  when let Ok(value) = parse(input) else {
    return Err("parse failed")
  }
  when let Admin(perms) = user.role else {
    return Err("unauthorized")
  }
  do_admin_thing(value, perms)
}

The else block must diverge: end it with return or panic, or, inside a loop body, with loop(...) to restart the loop. A boolean form (when cond else { ... }) also exists for flat guard sequences, and both forms can be mixed.

See Error Handling for the full treatment, including when to reach for when let-else instead of ?.

Exhaustiveness Checking

The compiler checks that your match covers all possible cases. Missing a variant produces a compile-time error. This is one of the strongest benefits of match as the sole branching construct.

The check looks inside patterns: the fields of constructors, tuples and records, the elements of list patterns, the alternatives of or-patterns. Arms may split a value any way they like, as long as together they leave nothing out:

fn describe(xs: List(Option(Int))) -> String {
  match xs {
    [] -> "empty"
    [None, ..rest] -> "starts with nothing"
    [Some(n)] -> "just {n}"
    [Some(n), _, ..rest] -> "{n} and more"
  }
}

fn main() {
  println(describe([Some(1), None]))
}
open in playground

An arm with a guard may not be taken, so it covers nothing. Number and string literals, ranges, pins and map patterns never cover their type between them: a match on such a value needs an arm that takes every value (_ or a name).

A match with so many interdependent patterns that the check cannot finish is an error too (“could not verify that the match is exhaustive”); a _ arm settles it.

Trade-off: no if. Simple boolean checks are more verbose (a match debug { ... } with a true arm and a false arm, each on its own line). In practice, guardless match and when-else cover most cases.