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 toT._: T— check the type without binding.
All four in one switch:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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):
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:
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:
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:
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:
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:
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.