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

PositionFormPattern must be
switch armPattern: { body },any (arms are tried in order)
if bindingif Pattern = subject { … } else { … }any (no match → else / skip)
if binding with guardif Pattern = subject; cond { … }any; binders are visible in cond
shapeofPattern shapeof subjectno binders (discards only; → bool)
destructuring declarationcon Pattern = exprirrefutable (see below)
destructuring parameterfn 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:

SlotMeaning
nbind the matched value; type inferred
_match anything, bind nothing
n: Tbind, and check or narrow the value to T
_: Tcheck the type without binding
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 and no type-binding form — matching is purely a reader:

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)
}

Pattern forms

FormExampleAccepted in
literal1:, 'a':, "go":, true:switch arms; payload slots
condition expressionn > 10:, c == hot:arms of a switch true (guard chain)
wildcard binder_, _: Tany slot
name bindern, n: Tany slot; top level of if bindings
variantDot, Dot(), Circle(r)switch arms, if bindings, declarations, payload slots
structPoint{ x }, Point{ x: a }, Point(a, b)declarations, parameters, if bindings, payload slots, switch typeof arms
tuple(l, r)destructuring declarations
typei32:, Point{ x }:switch typeof arms
catch-allany: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:

vyi
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):

vyi
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:

vyi
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:

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)
}

Patterns nest to any depth — a payload slot can hold another variant pattern or a struct pattern, matched through in one arm:

vyi
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:

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 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:

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: 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:

vyi
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:

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 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:

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)
}

Tuple patterns

Tuples destructure positionally in a destructuring declaration:

vyi
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:

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 },
        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:

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 },
    }
}

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:

vyi
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 _:

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 },
    }
}

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:

vyi-error
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:

SubjectRule
enumevery 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
booltrue 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:

vyi-error
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:

vyi
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.
vyi
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):

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)
}

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:

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 + 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 typeof arm, the subject itself narrows to the matched member;
  • a successful if v: T = u narrows v (and the branch) to T;
  • checks against singleton-union values subtract members for the rest of

the function — ==, !=, and if bindings all refine, 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) + 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.