Guides · 06

Pattern matching

Vyi has one pattern grammar, and it shows up in four places: switch arms, if bindings, destructuring con declarations, and destructuring parameters. Learn the binder rule once and every form follows from it. (For switch's arm syntax itself — the comma rule, expression form, guard chains — see guide 05 — Control flow; this guide is about the patterns inside the arms.)

Binders

A pattern is a shape with binding slots. The rule is small: a binding slot is a name or _, optionally followed by : Type.

  • n — bind the matched value; its type is inferred.
  • _ — match anything, bind nothing.
  • n: T — bind, and check or narrow the value to T.
  • _: T — check the type without binding.

All four in one switch:

vyi
enum Token {
    Num(i32),
    Pair(i32, i32),
    End,
}

fn classify(t: Token) i32 {
    switch t {
        Num(n):          { return n },        // bind, type inferred
        Pair(a: i32, _): { return a },        // bind with a checked type; skip the second
        End:             { return 0 },
    }
}

Pattern bindings are always con — there is no mut destructuring:

vyi-error
struct Pair { a: i32, b: i32 }

fn main() !u8 {
    con p = Pair{ a: 1, b: 2 }
    mut Pair(a, b) = p          // error: destructuring binds immutably
    return Ok(0)
}

To mutate a matched value, bind it with con and copy it into a mut. This keeps a pattern purely a reader — matching never gives you a mutable window into the subject.

Destructuring declarations

A con declaration can take a pattern instead of a plain name. Structs destructure by field name (with optional renaming) or positionally; tuples destructure positionally:

vyi
import { stdout } from "@io"

struct Point { x: i32, y: i32 }

fn main() !u8 {
    con p = Point{ x: 30, y: 12 }

    con Point{ x, y } = p       // by field name: x = 30, y = 12
    con Point{ x: a } = p       // rename: a = p.x; other fields may be omitted
    con Point(b, _) = p         // positional (declaration order): b = x, `_` skips y

    con t = (7, 35)
    con (l, r) = t              // tuples are positional: l = 7, r = 35

    stdout.writeInt(i64(x))   // prints 30
    stdout.write(" ")
    stdout.writeInt(i64(y))   // prints 12
    stdout.write(" ")
    stdout.writeInt(i64(a))   // prints 30
    stdout.write(" ")
    stdout.writeInt(i64(b))   // prints 30
    stdout.write(" ")
    stdout.writeInt(i64(l))   // prints 7
    stdout.write(" ")
    stdout.writeInt(i64(r))   // prints 35
    stdout.write("\n")
    return Ok(0)
}

A declaration must be irrefutable — it has no else-branch, so the pattern must be guaranteed to match. Structs and tuples always do. An enum with more than one variant does not, and the compiler rejects the attempt:

vyi-error
fn main() !u8 {
    con opt: ?i32 = Some(7)
    con Some(n) = opt           // error: Optional has two variants — this could fail
    return Ok(0)
}

The error points you at the refutable forms: switch, or if Some(…) = ….

Parameters destructure too — the pattern sits where the parameter name would:

vyi
import { stdout } from "@io"

struct Pair { x: i32, y: i32 }

fn sum(Pair(a, b): Pair) i32 {
    return a + b
}

fn main() !u8 {
    stdout.writeInt(i64(sum(Pair{ x: 30, y: 12 })))   // prints 42
    stdout.write("\n")
    return Ok(0)
}

switch arms

Variant patterns

An arm over an enum names a variant; parentheses destructure its payload. A slot can also hold a literal, which matches by equality — so specific payload values get their own arms, checked in order:

vyi
enum Shape { Dot, Circle(i32), Rect(i32, i32) }

fn describe(s: Shape) i32 {
    switch s {
        Dot:        { return 0 },
        Circle(1):  { return 3 },        // only the unit circle
        Circle(r):  { return 3 * r * r },
        Rect(w, h): { return w * h },
    }
}

Bare variant names work in patterns for the same reason they work in expressions: the subject's type is known, so Dot resolves against Shape without qualification. That applies to every enum — Some/None/Ok/Err in the arms of an Optional or Result switch are ordinary variants resolved exactly this way (guide 07).

Nested patterns

Patterns nest to any depth — a payload slot can hold another variant pattern, a struct pattern, or a tuple pattern, and one arm matches through all the layers at once:

vyi
enum Inner { A(i32), B }
enum Outer { Foo(Inner), Bar }

fn extract(o: Outer) i32 {
    switch o {
        Foo(A(n)): { return n },     // matches through both layers, binds the i32
        any:       { return 0 },
    }
}

A struct pattern inside a variant pulls fields straight out of the payload:

vyi
import { stdout } from "@io"

struct Pt { x: i32, y: i32 }
enum LoadErr { Bad }

fn make(ok: bool) LoadErr!Pt {
    if ok { return Ok(Pt{ x: 6, y: 1 }) }
    return Err(LoadErr.Bad)
}

fn main() !u8 {
    switch make(true) {
        Ok(Pt{ x }): {
            stdout.writeInt(i64(x))   // prints 6
            stdout.write("\n")
        },
        Err(_): {
            stdout.writeInt(99)
            stdout.write("\n")
        },
    }
    return Ok(0)
}

Narrowing to a payload's variant

When a payload is itself an enum, a binder's : Type may name one of that enum's variants — the arm then matches only when the payload carries that variant:

vyi
enum ReadError { Interrupted, Closed, Frozen }

fn read(n: i32) ReadError!i32 {
    if n == 1 { return Err(Interrupted) }
    if n == 2 { return Err(Closed) }
    if n == 3 { return Err(Frozen) }
    return Ok(n * 10)
}

fn classify(n: i32) i32 {
    switch read(n) {
        Ok(v):               { return v },
        Err(_: Interrupted): { return 1 },   // match the variant, bind nothing
        Err(e: Closed):      { return 2 },   // match the variant AND bind the payload
        Err(e: _):           { return 3 },   // every other error
    }
}

A variant-narrowed arm covers only part of its variant, and exhaustiveness knows it — drop the final Err(e: _) arm (leaving Frozen and Closed unhandled) and the switch is rejected:

vyi-error
enum ReadError { Interrupted, Closed }

fn read(n: i32) ReadError!i32 {
    if n == 1 { return Err(Interrupted) }
    if n == 2 { return Err(Closed) }
    return Ok(n)
}

fn classify(n: i32) i32 {
    switch read(n) {
        Ok(v):               { return v },
        Err(_: Interrupted): { return 1 },
    }   // error: an Err carrying Closed matches no arm
}

Multi-pattern arms

Comma-separated patterns before the : are alternatives sharing one body:

vyi
enum Color { Red, Green, Blue }

fn warm(c: Color) i32 {
    switch c {
        Red, Green: { return 1 },
        Blue:       { return 0 },
    }
}

Alternatives match without binding — a binder would be undefined whenever one of the other alternatives matched:

vyi-error
enum E { Bad }

fn classify(r: E!i32) i32 {
    switch r {
        Ok(n), Err(_): { return 1 },   // error: `n` is undefined when Err matched
        any:           { return 0 },
    }
}

Type patterns: switching over a union

A union value (i32 | Point) carries which member type it currently holds. switch typeof u dispatches on that member; a type-name arm selects it, and a struct pattern in arm position destructures the matched member directly:

vyi
struct Point { x: i32, y: i32 }

fn pick(b: bool) i32 | Point {
    if b { return Point{ x: 6, y: 7 } }
    return 5
}

fn area(b: bool) i32 {
    con u = pick(b)
    switch typeof u {
        Point{ x, y }: { return x * y },   // destructure the matched member
        i32:           { return u + 1 },   // u narrows to i32 in this arm
    }
}

The typeof is required — a switch over the union value matches ordinary patterns, so bare type names there are just undefined names:

vyi-error
fn pick(b: bool) i32 | bool {
    if b { return 7 }
    return false
}

fn classify(b: bool) i32 {
    con u = pick(b)
    switch u {              // error: member dispatch needs `switch typeof u`
        i32:  { return 1 },
        bool: { return 2 },
    }
}

Exhaustiveness

A switch must account for every value its subject can hold:

  • over an enum, every variant needs an arm (a variant-narrowed arm counts only for its variant);
  • over a union (switch typeof), every member type needs an arm — including singleton unions like 'a' | 'b' | 'q', where the missing members are reported individually;
  • over an open subject (plain integers, strings), the equality arms can never be complete, so a catch-all is required.

The escape hatch in all three is the catch-all arm, spelled any — not _, which is the inference/discard marker and is rejected in arm position (guide 05).

if bindings

if accepts a refutable pattern: if Pattern = subject { … } else { … }. When the pattern matches, its binders are in scope in the then-block; when it doesn't, the else runs (or the whole statement is skipped):

vyi
import { stdout } from "@io"

fn main() !u8 {
    con o: ?i32 = Some(33)
    mut r = 0
    if Some(v) = o {
        r = v          // v is the unwrapped i32
    } else {
        r = 99
    }
    stdout.writeInt(i64(r))   // prints 33
    stdout.write("\n")
    return Ok(0)
}

When you only need a boolean — “does this match?” — use shapeof instead of if … { true } else { false }. Binders are not allowed there:

vyi
import { stdout } from "@io"

fn main() !u8 {
    con o: ?i32 = Some(1)
    con hit = Some(_) shapeof o    // true
    con miss = None() shapeof o    // false
    if hit && !miss {
        stdout.write("ok\n")
    } else {
        stdout.write("miss\n")
    }
    return Ok(0)
}

Everything from the switch-arm grammar carries over — nested patterns (if Foo(A(m)) = o), struct patterns (if Point{ x, y } = p), multi-field payloads (if Pair(a, b) = p). A payload-less variant is written as a call, Green(), so the pattern isn't read as a plain name binding:

vyi
import { stdout } from "@io"

enum Color { Red, Green, Blue }

fn main() !u8 {
    con c: Color = Green
    con r = if Green() = c { 1 } else { 0 }
    stdout.writeInt(i64(r))   // prints 1
    stdout.write("\n")
    return Ok(0)
}

Guards, chains, and expression position

A ; guard after the subject adds a condition; the binders are visible in the guard and in the then-block. The then-branch runs only when the pattern matches and the guard holds, and else if chains work as usual:

vyi
import { stdout } from "@io"

enum ParseErr { Empty }

fn parse(s: [*]u8) ParseErr!i32 {
    if s.len == 0 { return Err(Empty) }
    return Ok(i32(s[0]) - 48)
}

fn main() !u8 {
    con r = parse("7")
    con out = if Ok(n) = r; n > 5 {
        n           // matches AND n > 5 → 7
    } else if Ok(n) = r {
        n + 100     // matches, guard failed
    } else {
        0           // no match
    }
    stdout.writeInt(i64(out))   // prints 7
    stdout.write("\n")
    return Ok(0)
}

Like every if, the binding form works in value position — each branch yields its tail expression:

vyi
import { stdout } from "@io"

fn pick(n: i32) i32 | bool {
    if n > 2 { return 99 }
    return true
}

fn main() !u8 {
    con o: ?i32 = Some(5)
    con a = if Some(v) = o { v } else { 0 }         // 5: payload bind

    con u = pick(7)
    con b = if v: i32 = u { v } else { 0 }          // 99: union member narrow

    con mode: "fast" | "slow" = "slow"
    con c = if s: "slow" = mode { 20 } else { 0 }   // 20: singleton match

    stdout.writeInt(i64(a))   // prints 5
    stdout.write(" ")
    stdout.writeInt(i64(b))   // prints 99
    stdout.write(" ")
    stdout.writeInt(i64(c))   // prints 20
    stdout.write("\n")
    return Ok(0)
}

The middle form deserves a second look: if v: i32 = u is the binder rule applied at the top level. Over a union subject it's a runtime check — "if u currently holds an i32, bind it as v" — the if-shaped cousin of switch typeof.

Static narrowing

A successful match doesn't just bind values — it tells the compiler things it then uses. Inside a switch typeof arm the subject itself narrows to the matched member (that's why u + 1 type-checked as i32 arithmetic above). And a check against a singleton-union value subtracts members for the rest of the function — ==, !=, and if-let all trigger it, and refinements chain:

vyi
import { stdout } from "@io"

fn only3(x: 3) i32 { return 30 }

fn pick(n: 1 | 2 | 3) i32 {
    if n == 1 { return 10 }
    // n: 2 | 3 from here on
    if v: 2 = n { return 20 }
    // n: 3 — the compiler will hold you to it
    return only3(n)
}

fn main() !u8 {
    stdout.writeInt(i64(pick(1)))   // prints 10
    stdout.write(" ")
    stdout.writeInt(i64(pick(2)))   // prints 20
    stdout.write(" ")
    stdout.writeInt(i64(pick(3)))   // prints 30
    stdout.write("\n")
    return Ok(0)
}

After the early returns, n can only be 3 — so passing it to a function that demands exactly 3 compiles. Narrowing is a consequence of matching, not a separate feature: every pattern form in this guide feeds the same type information back into the branch it guards.

See also: reference/patterns.