Docs · Language
Patterns
Vyi has one pattern grammar, used in five positions: switch arms, if bindings,
shapeof match tests, destructuring con declarations, and destructuring
parameters. This page is the precise catalogue of the pattern forms, where each is
accepted, and the exhaustiveness and refutability rules built on them. For the
narrative introduction, read guide 06 — Pattern matching.
Where patterns appear
| Position | Form | Pattern must be |
|---|---|---|
switch arm | Pattern: { body }, | any (arms are tried in order) |
if binding | if Pattern = subject { … } else { … } | any (no match → else / skip) |
if binding with guard | if Pattern = subject; cond { … } | any; binders are visible in cond |
shapeof | Pattern shapeof subject | no binders (discards only; → bool) |
| destructuring declaration | con Pattern = expr | irrefutable (see below) |
| destructuring parameter | fn f(Pattern: Type) … | irrefutable struct pattern |
All positions share the grammar below, with the position-specific restrictions called out per form.
The binder rule
A pattern is a shape with binding slots. A binding slot is a name or _,
optionally followed by : Type:
| Slot | Meaning |
|---|---|
n | bind the matched value; type inferred |
_ | match anything, bind nothing |
n: T | bind, and check or narrow the value to T |
_: T | check the type without binding |
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 and no
type-binding form — matching is purely a reader:
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)
}Pattern forms
| Form | Example | Accepted in |
|---|---|---|
| literal | 1:, 'a':, "go":, true: | switch arms; payload slots |
| condition expression | n > 10:, c == hot: | arms of a switch true (guard chain) |
| wildcard binder | _, _: T | any slot |
| name binder | n, n: T | any slot; top level of if bindings |
| variant | Dot, Dot(), Circle(r) | switch arms, if bindings, declarations, payload slots |
| struct | Point{ x }, Point{ x: a }, Point(a, b) | declarations, parameters, if bindings, payload slots, switch typeof arms |
| tuple | (l, r) | destructuring declarations |
| type | i32:, Point{ x }: | switch typeof arms |
| catch-all | any: | switch arms |
Literal arms
Over a non-enum subject (integers, chars, strings, bool), a switch arm is a
literal matched against the subject with ==, in arm order:
import { stdout } from "@io"
fn code(c: char) i32 {
switch c {
'a': { return 1 },
'x': { return 2 },
any: { return 9 },
}
}
fn main() !u8 {
stdout.writeInt(i64(code('a') * 16 + code('x') * 4 + code('q'))) // prints 16 + 8 + 9 = 33
stdout.write("\n")
return Ok(0)
}A name in arm position is read as a pattern, not a value — a bare name arm over
a value subject is rejected (value switch arm: unsupported pattern), never
treated as a fresh top-level binder or as an equality test against a binding.
Top-level binders exist in if bindings, not in arms.
To match against a computed value, switch over true and let each arm be a
condition — the guard-chain idiom (guide
05):
import { stdout } from "@io"
fn code(c: char) i32 {
con hot = 'x'
switch true {
c == 'a': { return 1 },
c == hot: { return 2 },
any: { return 9 },
}
}
fn main() !u8 {
stdout.writeInt(i64(code('x'))) // prints 2
stdout.write("\n")
return Ok(0)
}Variant patterns
An arm over an enum names a variant bare — the subject's type resolves it, so
no qualification is needed (this is the same bare-variant resolution every enum
gets, Some/None/Ok/Err included). Parentheses destructure the payload;
a payload-less variant may be written with or without empty parentheses:
enum Shape { Dot, Circle(i32), Rect(i32, i32) }
fn describe(s: Shape) i32 {
switch s {
Dot: { return 0 }, // bare; `Dot()` is also accepted
Circle(1): { return 3 }, // literal in a payload slot: equality
Circle(r): { return 3 * r * r },
Rect(w, h): { return w * h },
}
}Arms are tried in order, so a literal-payload arm (Circle(1)) belongs above the
binder arm that would otherwise swallow it. A qualified path (Shape.Dot:) is
not a variant pattern — it is an expression arm, binds nothing, and does not
count toward exhaustiveness; write variants bare.
In an if binding, a payload-less variant must use the call form so the pattern
isn't read as a plain name:
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)
}Patterns nest to any depth — a payload slot can hold another variant pattern or a struct pattern, matched through in one arm:
enum Inner { A(i32), B }
enum Outer { Foo(Inner), Bar }
struct Pt { x: i32, y: i32 }
enum Load { At(Pt), Missing }
fn extract(o: Outer) i32 {
switch o {
Foo(A(n)): { return n }, // through both enum layers
any: { return 0 },
}
}
fn xOf(l: Load) i32 {
switch l {
At(Pt{ x }): { return x }, // struct pattern inside a payload
Missing: { return -1 },
}
}Binder 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; _: Variant checks without binding, e: Variant also binds, and
e: _ takes the remainder:
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 AND bind
Err(e: _): { return 3 }, // every other error
}
}Exhaustiveness accounts for the split exactly: a variant-narrowed arm covers
the slice of its variant carrying that inner variant, and narrowed arms that
together name every inner variant cover the whole variant — no Err(e: _)
remainder arm needed. Here the arms stop at Interrupted, so Closed and
Frozen are unhandled and the switch is rejected, naming the missing slices:
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: non-exhaustive match; missing Err(Closed)
}Binder narrowing to a union's member
When a payload is a union — the error slot of a multi-member inferred
error set is the common case — a binder's : Type may name one of its member
types. The arm then matches only when the union currently holds that member,
and the binder is the member value:
import { stdout } from "@io"
enum SaveError { DiskFull }
enum NetError { Timeout }
fn save(ok: bool) SaveError!() {
if ok { return Ok(()) }
return Err(SaveError.DiskFull)
}
fn send(ok: bool) NetError!() {
if ok { return Ok(()) }
return Err(NetError.Timeout)
}
fn sync(a: bool, b: bool) ! { // inferred error set: SaveError | NetError
try save(a)
try send(b)
return Ok(())
}
fn classify(a: bool, b: bool) i32 {
switch sync(a, b) {
Ok(_): { return 0 },
Err(se: SaveError): { return 1 }, // only a SaveError lands here
Err(ne: NetError): { return 2 }, // only a NetError lands here
}
}
fn main() !u8 {
stdout.writeInt(i64(classify(false, true) * 10 + classify(true, false))) // prints 12
stdout.write("\n")
return Ok(0)
}Exhaustiveness treats a member-narrowed arm exactly like a variant-narrowed
one: it covers the slice of Err carrying that member, and the member-narrowed
arms above together span the whole error set (SaveError | NetError), so the
switch is exhaustive with no catch-all. Cover only some members and the switch
is rejected, naming the missing slices (missing Err(NetError)); an
un-narrowed Err(_: _) arm takes the remainder when handling each member
separately isn't wanted.
Struct patterns
A struct pattern matches by field name, with optional rename (field: name),
or positionally in declaration order. Omitted fields are simply not bound:
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 omitted
con Point(b, _) = p // positional: b = x, `_` skips y
stdout.writeInt(i64(x + y + a - b + 30)) // prints 42
stdout.write("\n")
return Ok(0)
}Naming a field the struct doesn't have is an error. Struct patterns are accepted
in destructuring declarations, parameters, if bindings, payload slots, and
switch typeof arms — a value switch over a struct subject does not take
struct-pattern arms (there is nothing to discriminate; use a declaration or an
if binding instead).
As a parameter, the pattern sits where the 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)
}Tuple patterns
Tuples destructure positionally in a destructuring declaration:
import { stdout } from "@io"
fn main() !u8 {
con t = (7, 35)
con (l, r) = t // l = 7, r = 35
stdout.writeInt(i64(l + r)) // prints 42
stdout.write("\n")
return Ok(0)
}Type patterns: switch typeof
A union value (i32 | Point) carries which member type it holds.
switch typeof u dispatches on the member: a type-name arm selects it, and a
struct pattern in arm position destructures the matched member directly. Inside
an arm, the subject narrows to that member:
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 },
i32: { return u + 1 }, // u is i32 in this arm
}
}The typeof is required for member dispatch — a switch over the union value
matches ordinary patterns, so bare type names there are 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 },
}
}The if-shaped equivalent is a top-level binder with a type: if v: i32 = u
matches when u currently holds an i32 and binds it (see
if bindings below).
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 must not bind — a binder would be undefined whenever one of the
other alternatives matched. Payload slots in alternatives are limited to _:
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 },
}
}The any catch-all
The catch-all arm is spelled any. It matches every remaining value, and it
alone makes a switch over an open subject exhaustive. _ is the
inference/discard marker, not an arm:
fn main() !u8 {
con r = switch 3 { 1: 1, _: 0 } // error: the catch-all arm is `any`
return Ok(0)
}Arms are tried in order; any matches unconditionally, so it belongs last.
Exhaustiveness
A switch must account for every value its subject can hold:
| Subject | Rule |
|---|---|
| enum | every variant needs an arm; narrowed arms (Err(_: Interrupted), Err(se: SaveError)) count exactly — together naming every inner variant / union member covers the variant |
union (switch typeof) | every member type needs an arm |
singleton union (1 | 2, 'a' | 'b') | a value switch listing every member is exhaustive |
bool | true and false arms cover both values |
| open subjects (integers, chars, strings) | literal arms can never be complete — any is required |
Missing cases are reported by name:
enum Color { Red, Green, Blue }
fn hue(c: Color) i32 {
switch c {
Red: { return 1 },
Green: { return 2 },
} // error: non-exhaustive match; missing Blue
}
fn main() !u8 { return Ok(0)) }A complete singleton-union switch and a complete bool switch need no any:
import { stdout } from "@io"
fn speed(m: "fast" | "slow") i32 {
switch m {
"fast": { return 2 },
"slow": { return 1 },
}
}
fn main() !u8 {
con b = true
con sign = switch b { true: 1, false: 0 }
stdout.writeInt(i64(speed("fast") + sign)) // prints 3
stdout.write("\n")
return Ok(0)
}One asymmetry: the all-paths-return analysis credits enum and singleton-union
completeness (speed above needs no trailing return) but not bool
completeness — a statement-form bool switch whose arms all return still
needs an any arm or a trailing return. In expression position the bool
switch is complete as shown.
Refutability of destructuring declarations
A destructuring declaration has no else-branch, so its pattern must be irrefutable — guaranteed to match:
- struct and tuple patterns always match their own type: irrefutable.
- a variant pattern is irrefutable only when the enum has exactly one
variant.
- literal patterns and multi-pattern alternatives are not declaration forms.
import { stdout } from "@io"
enum One { Only(i32) }
fn main() !u8 {
con o = One.Only(5)
con Only(n) = o // one variant — cannot fail
stdout.writeInt(i64(n))
stdout.write("\n")
return Ok(0)
}An enum with more than one variant is refutable, and the declaration is
rejected — the error points at the refutable positions (switch, if):
fn main() !u8 {
con opt: ?i32 = Some(7)
con Some(n) = opt // error: Optional has two variants — this could fail
return Ok(0)
}Binders at the top level of if
if Pattern = subject accepts every form above, plus a top-level binder with a
: Type narrow — over a union it is the runtime member test, over a singleton
union a value test:
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 + b + c - 100)) // prints 24
stdout.write("\n")
return Ok(0)
}A ; guard after the subject adds a condition evaluated with the binders in
scope; the branch runs only when the pattern matches and the guard holds.
Static narrowing
Matching feeds type information back into the branch it guards:
- in a
switch typeofarm, the subject itself narrows to the matched member; - a successful
if v: T = unarrowsv(and the branch) toT; - checks against singleton-union values subtract members for the rest of
the function — ==, !=, and if bindings all refine, 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) + pick(2) + pick(3) - 23)) // prints 37
stdout.write("\n")
return Ok(0)
}See also: guide 06 — Pattern matching,
guide 05 — Control flow for switch's arm
syntax (the comma rule, expression form),
reference/statements for declaration syntax.