Patterns

Pattern matching

Match is an expression. Destructure literals, tuples, enums, and structs — and let the checker prove you handled every case.

In Lira, match is an expression: each arm yields a value, and the whole match evaluates to the arm that fits. You reach for it constantly — to branch on a number, pull data out of an enum, or classify a point by which axis it sits on. And because the checker enforces exhaustiveness on enums, a match that forgets a case is a compile error, not a lurking bug.

Literal patterns

The simplest patterns are literals — integers, strings, and booleans. Arms are tried top to bottom; a wildcard _ is the catch-all that matches anything left over. Since match is an expression, you can return it directly.

literals.li
// `match` is an expression: every arm yields a value, and the
// whole match evaluates to the arm that fits. Here we match on
// integer literals, with a wildcard `_` as the catch-all.
fn grade(score: int) -> string {
    return match score {
        100 => "perfect",
        90 => "A",
        80 => "B",
        _ => "keep going"
    }
}

// String and bool literals match the same way.
fn greet(lang: string) -> string {
    return match lang {
        "en" => "hello",
        "no" => "hei",
        _ => "..."
    }
}

println(grade(100))
println(grade(80))
println(grade(42))
println(greet("no"))
println(greet("fr"))

Bindings and guards

A bare name in a pattern is a binding: it matches anything and binds the value so you can use it on the right-hand side. Add a guardif cond — to refine an arm so it only fires when the condition holds. Guards are checked in order alongside the patterns themselves.

bindings.li
// A bare name in a pattern is a binding: it matches anything and
// binds the value, so you can use it on the right-hand side. A guard
// (`if cond`) refines an arm — the arm only fires when the guard holds.
fn classify(n: int) -> string {
    return match n {
        0 => "zero",
        x if x < 0 => "negative",
        x if x % 2 == 0 => "even",
        x => "odd: ${x}"
    }
}

println(classify(0))
println(classify(-7))
println(classify(8))
println(classify(3))

Tuple patterns

Tuple patterns destructure positionally, and each element can be a literal, a binding, or another tuple. Mixing literal and binding elements is where they shine — classifying a point by which axis it lands on falls right out.

tuples.li
// Tuple patterns destructure positionally — and the elements can be
// literals, bindings, or nested tuples. Classifying a point by which
// axis it sits on falls out naturally.
fn quadrant(p: (int, int)) -> string {
    return match p {
        (0, 0) => "origin",
        (0, y) => "on the y-axis",
        (x, 0) => "on the x-axis",
        (x, y) => "at (${x}, ${y})"
    }
}

// Nesting works too: a pattern can reach into an inner tuple.
fn label(p: ((int, int), int)) -> string {
    return match p {
        ((0, 0), z) => "axis origin, depth ${z}",
        ((x, y), 0) => "flat at (${x}, ${y})",
        ((x, y), z) => "point in space"
    }
}

println(quadrant((0, 0)))
println(quadrant((0, 5)))
println(quadrant((3, 4)))
println(label(((0, 0), 9)))
println(label(((1, 2), 0)))

Nested tuple patterns reach into inner tuples in the same arm, with literal elements at any depth. The compiler emits the equality checks for you, so (0, 0) really only matches the origin.

Enum and constructor patterns

Constructor patterns match enum variants. Qualify the variant as Enum::Variant, and bind any payload right inside the pattern — single values, multiple fields, or none at all.

enums.li
// Constructor patterns match enum variants. Qualify the variant with
// `Enum::Variant`, and bind any payload right in the pattern.
enum Shape {
    Circle(int),
    Rect(int, int),
    Point
}

fn area(s: Shape) -> int {
    return match s {
        Shape::Circle(r) => 3 * r * r,
        Shape::Rect(w, h) => w * h,
        Shape::Point => 0
    }
}

println(area(Shape::Circle(10)))
println(area(Shape::Rect(3, 4)))
println(area(Shape::Point))

The same shape powers your own Option- or Result-like types. A generic enum carries a payload of any type, and matching binds it:

option.li
// A generic enum makes its own Option type. Matching binds the
// payload of `Some`, and the checker forces you to handle `None`.
enum Opt<T> {
    Some(T),
    None
}

fn unwrap_or(o: Opt<int>, fallback: int) -> int {
    return match o {
        Opt::Some(v) => v,
        Opt::None => fallback
    }
}

println(unwrap_or(Opt::Some(42), 0))
println(unwrap_or(Opt::None, -1))

See Types for more on generic enums, optionals (T?), and Result with the ? operator.

Struct patterns

Struct patterns destructure named fields. In a let binding you can pull the fields you want straight into local names — by shorthand ({ x, y }) or by renaming ({ x: ox, y: oy }).

structs.li
// Struct patterns destructure named fields in a `let` binding: pull
// the fields you want straight out of a value into local names.
struct Point {
    x: int,
    y: int
}

fn main() {
    let p = Point { x: 3, y: 4 }

    // Destructure both fields by name.
    let { x, y } = p
    println("x is ${x}, y is ${y}")

    // Shorthand binds each field to a variable of the same name.
    let origin = Point { x: 0, y: 0 }
    let { x: ox, y: oy } = origin
    println("origin: (${ox}, ${oy})")
}

main()

Exhaustiveness

Here is the part worth leading with. When you match on an enum, the checker verifies that every variant is handled. Cover all of them — or add a wildcard — and it compiles. Miss one, and compilation stops with a located error before any bytecode is generated.

This match covers all three variants, so it is accepted:

exhaustive.li
// Matching every variant of an enum satisfies the exhaustiveness
// checker. No wildcard needed — the compiler can see all cases are
// covered, so this compiles and runs.
enum Signal {
    Red,
    Amber,
    Green
}

fn action(s: Signal) -> string {
    return match s {
        Signal::Red => "stop",
        Signal::Amber => "slow",
        Signal::Green => "go"
    }
}

println(action(Signal::Red))
println(action(Signal::Amber))
println(action(Signal::Green))

Drop the Green arm with no wildcard to fall back on, and the checker refuses it:

nonexhaustive.li
// @expect-error
// This match omits `Signal::Green` and has no wildcard, so the
// checker rejects it at compile time — before any bytecode runs.
enum Signal {
    Red,
    Amber,
    Green
}

fn action(s: Signal) -> string {
    return match s {
        Signal::Red => "stop",
        Signal::Amber => "slow"
    }
}

println(action(Signal::Red))

Two ways to satisfy it: list every variant, or add a _ arm. The first is usually better — when you later add a fourth signal, the compiler will walk you to every match that needs updating, instead of silently funnelling the new case into a wildcard.

Where to go next

Pattern matching pairs naturally with the rest of the language: bind received values in a select, model errors as enums and match on them (Types), or start from the top with the guide. Every snippet on this page is a real .li file in the repository, verified to run before this site is built.