Docs · Language

Statements and declarations

This page catalogues everything that can stand as a statement: declarations, assignment, the control statements (if, switch, for, break/continue, return), defer/panic/recover, and the block and scoping rules they all live under. Guide 05 — Control flow introduces the control statements; guide 08 covers the defer/panic machinery. Statement separation (newlines, ;) is defined in lexical.

Files and the top level

A .vyi file is a sequence of top-level declarations. Declarations — fn, struct, enum, interface, type — are hoisted: visible throughout their scope regardless of order, in the file and across files, so nothing needs a forward declaration. File-scope con and mut bindings are the exception: they are source-ordered — an initialiser reads only bindings declared above it.

Three further forms appear only at file scope:

  • import / exportimport { name } from "path" binds exported names

directly into the file's scope (no namespace object); export marks a declaration importable. Guide 14.

  • Compile-time directives — a file-scope if (over a known condition) selects

which declarations exist; a file-scope for (over a known iterable) stamps out declarations per element. Known values.

  • c_include — names a C library for linking

(guide 15).

Declarations

Declarations are statements: every form below works at file scope and inside a function body (export is top-level only).

con and mut

vyi
con limit = 42            // immutable, type inferred
con pi: f64 = 3.14159     // explicit type
mut total = 0             // mutable — reassignment allowed
total = total + limit

Every binding takes a value — mut n: i32 without = value does not parse; there is no "declare now, initialise later" and no implicit zero. A mut keeps its type for life: new values, never a new type. Assigning to a con is an error.

A con may take a pattern instead of a name, destructuring structs (by field name or position) and tuples (by position). The pattern must be irrefutable — an enum with multiple variants can't be destructured this way (use switch or an if binding), and destructuring always binds con:

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
    con Point(a, _) = p         // positional; _ discards
    con (l, r) = (5, 7)         // tuples are positional
    stdout.writeInt(i64(x + y + a + l + r - 77))
    stdout.write("\n")
    return Ok(0)
}

The full pattern grammar is the patterns reference.

type

type Name = expr binds a name to a known value — usually a type — and is hoisted like fn. An alias is fully interchangeable with what it names; and because any known value can stand in type position, type Two = 2 and type Mode = "fast" | "slow" are valid too (types § singleton types).

fn

vyi
fn add(a: i32, b: i32) i32 {         // free function
    return add1(a, b - 1)
}

fn add1(a: i32, b: i32) i32 { return a + b + 1 }

struct Point { x: i32, y: i32 }

fn (p: Point) sum() i32 {            // method: value receiver
    return p.x + p.y
}

fn (p: *mut Point) shift(d: i32) {   // method: pointer receiver — may mutate
    p.x = p.x + d
}

fn (Point) origin() Point {          // type receiver — called on the type
    return Point{ x: 0, y: 0 }
}

Parameters are name: Type, optionally with a default (step: i32 = 1) or the known qualifier (compile-time parameter — known values); the return type follows the parameter list, and omitting it means (). A receiver clause before the name makes the declaration a method — a free-standing declaration resolved by scope, not a member (methods & dispatch). Overloading is by full signature (expressions § calls).

fn declarations nest — a function body can declare local functions — and hoist within whatever scope holds them.

struct, enum, interface

Each declares a nominal type; each is shorthand for a hoisted type binding (struct P { … }type P = struct { … }). Bodies are comma-delimited: fields name: Type (with optional pub and = default), variants Name / Name(T, …), method signatures fn name(…) Ret,. Details: types, guides 03 and 12.

export and pub

export before a top-level declaration makes its name importable by other files; everything else is file-private. pub is the field-level counterpart on struct fields. export { list } re-exports imported names (the barrel pattern). Guide 14.

C declarations

c_fn (extern prototype, or a Vyi body emitted under a C name), c_struct, c_enum, c_union (C-layout data types, visible through the c. namespace), and c_include (link a library). All follow C's rules, not Vyi's; guide 15 is the reference.

Assignment

place = value stores into a place: a mut binding, a field, an index, a dereference — composed to any depth:

vyi
import { stdout } from "@io"

struct P { x: i32 }

fn main() !u8 {
    mut n = 1
    mut p = P{ x: 1 }
    mut arr = [2]i32[1, 2]
    con ptr = &n

    n = 2            // binding
    p.x = 2          // field
    arr[0] = 2       // index (desugars to @setIndex)
    *ptr = 3         // through a pointer
    con pp = &p
    pp.x = 4         // field through a pointer (auto-deref)
    stdout.writeInt(i64(n + p.x + arr[0] - 9))
    stdout.write("\n")
    return Ok(0)
}

Assignment is a statement, not an expression — it produces no value, so a = b = c and if a = b { … } don't exist (the if Pattern = subject form below is a pattern binding, not an assignment). There is no discard target: to ignore a result, just call in statement position.

Compound assignment

All ten forms: += -= *= /= %= &= |= ^= <<= >>=. By default place OP= value rewrites to place = place OP value with the place evaluated once; a type can override the compound form directly with a @comp* method (guide 13, operators).

"Evaluated once" is enforced: a place containing a call would run the call twice under the rewrite, so it is rejected — bind the index first:

vyi-error
fn pick() i32 { return 0 }

fn main() !u8 {
    mut buf = [2]i32[1, 2]
    buf[pick()] += 1     // error: would evaluate the place twice; bind pick() first
    return Ok(0)
}

Expression statements

Any expression may stand as a statement; its value, if any, is discarded. The common case is a call run for effect. try f() in statement position propagates the failure and discards the success.

Blocks and scope

{ … } is a block: a new lexical scope. Bindings declared inside end with it; declaring a name that exists outside shadows it (a new binding — the right-hand side still sees the old one). Scoping is purely lexical:

vyi
import { stdout } from "@io"

fn main() !u8 {
    con x = 5
    con x = x * 2      // shadowing: new binding, RHS sees the old (10)
    {
        con x = 100    // inner shadow, dies with the block
        con y = x
    }
    stdout.writeInt(i64(x))  // prints 10 — `y` is gone too
    stdout.write("\n")
    return Ok(0)
}

A block in value position is an expression (expressions § block expressions); a labeled block is a break target (below).

if

vyi
fn classify(n: i32) i32 {
    if n < 0 {
        return -1
    } else if n == 0 {
        return 0
    } else {
        return 1
    }
}

No parentheses around the condition; braces always required; the condition must be bool (integers are not truthy). else and else if chain as usual.

if also takes a refutable pattern bindingif Pattern = subject { … } — whose binders are in scope in the then-block, with an optional ; guard condition after the subject:

vyi
import { stdout } from "@io"

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

Both forms work as expressions too (expressions, patterns).

switch

switch subject { … } matches the subject against arms, top to bottom; the first matching arm's body runs. Each arm is Pattern: { body }, and patterns bind enum payloads directly:

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

fn area(s: Shape) i32 {
    switch s {
        Dot:        { return 0 },
        Circle(r):  { return 3 * r * r },
        Rect(w, h): { return w * h },
    }
}

The rules, each specified fully elsewhere:

  • Exhaustiveness — an enum subject needs every variant handled; a union

(switch typeof u) every member; an open subject (integers, strings) needs the catch-all. The catch-all arm is spelled any: — not _, not else (patterns § exhaustiveness).

  • The comma rule — multi-line switches require a comma after every arm,

including the last; a single-line switch separates arms with commas and takes no trailing one.

  • Multi-pattern armsRed, Green: { … } shares one body between

alternatives (which therefore can't bind payloads).

  • Guard chains — there is no per-arm guard; switch true with condition arms

covers it (guide 05).

Missing the comma is a parse error:

vyi-error
fn label(n: i32) i32 {
    switch n {
        1: { return 10 }     // error: missing "," after the arm
        any: { return 99 },
    }
}

fn main() !u8 { return Ok(0)) }

for

The one loop keyword, three forms:

Condition (a while-loop) and infinite:

vyi
import { stdout } from "@io"

fn main() !u8 {
    mut n = 5
    mut sum = 0
    for n > 0 {          // condition form
        sum = sum + n
        n = n - 1
    }
    mut i = 0
    for {                // infinite form — exit with break/return
        i = i + 1
        if i == 5 { break }
    }
    stdout.writeInt(i64(sum + i))  // prints 15 + 5
    stdout.write("\n")
    return Ok(0)
}

for x in iterable — iterates a range (written in place), a fixed array [N]T, an array pointer [*]T, or any type implementing the iteration protocol (an @index(i32) method plus a len() method — what Vec and String provide). The loop variable is a fresh immutable binding each iteration:

vyi
import { stdout } from "@io"

fn main() !u8 {
    mut s = 0
    for x in 1..5 { s = s + x }          // ranges: 1+2+3+4
    con xs = [3]i32[10, 20, 2]
    for x in xs { s = s + x }            // arrays
    for b in "ab" { s = s + i32(b) }     // a string is a [*]u8 — bytes
    stdout.writeInt(i64(s - 200))  // prints 10 + 32 + 195 − 200 = 37
    stdout.write("\n")
    return Ok(0)
}

Assigning to the loop variable is rejected (it is a con). for x in 1...5 treats the bound inclusively — a property of the syntactic site, not of a stored Range value (expressions § ranges).

Labels, break, and continue

break exits the nearest enclosing loop; continue skips to its next iteration. A label — #name prefixing a for or a block — lets them target an ancestor instead; there is no sideways jump and no goto:

vyi
import { stdout } from "@io"

fn main() !u8 {
    mut cells = 0
    #rows for row in 0..5 {
        for col in 0..5 {
            if row == 3 { break #rows }        // exit both loops
            if col == row { continue #rows }   // next row
            cells = cells + 1
        }
    }
    stdout.writeInt(i64(cells))  // prints 3
    stdout.write("\n")
    return Ok(0)
}

break #name also exits a labeled block; continue is loop-only — a block is never a continue target. break value / break #name value yield a value from a value-position block only (expressions); in a loop or statement block, break is always plain.

return

return expr exits the function with a value; bare return exits a ()-returning function. Every path through a value-returning function must return — falling off the end is an error unless the function returns (). In a fallible function the returned expression feeds the failure channel like any other value-flow site (return Err(e), return Ok(try f(x))error model).

A return inside a defer body replaces the function's return value (below).

defer

defer stmt or defer { … } schedules code to run when the enclosing function exits:

vyi
import { stdout } from "@io"

fn greet() {
    defer stdout.write("cleanup\n")
    stdout.write("body\n")
}

fn main() !u8 {
    greet()               // prints "body", then "cleanup"
    return Ok(0)
}

The semantics, precisely:

  • Every exit path runs the defers: normal return, falling off the end, an

error propagating out through try, and a panic unwinding through the frame.

  • LIFO order — the last defer registered runs first.
  • Registration is dynamic — a defer registers when execution reaches it (one

registration per reach, so a defer in a loop body registers each iteration), and belongs to the function, not the block it sits in.

  • The body sees final values — it evaluates at exit, with full access to the

function's locals as they are then, not as they were at registration.

  • return inside a defer replaces the function's return value — and during a

panic unwind, halts the unwind. This is the mechanism recover builds on.

Guide 08 walks through all of these.

panic

panic reason — the reason is a [*]u8 (a string literal works directly) — aborts the current line of execution and unwinds the stack, running each frame's defers on the way out. Unrecovered, it prints the reason plus the panic site and exits the process with status 101. Panics are for invariant violations, not expected failures — anything a caller should handle belongs in an error union.

vyi
import { stdout } from "@io"

fn checked(x: i32) i32 {
    if x < 0 { panic "negative input" }
    return x * 2
}

fn main() !u8 {
    stdout.writeInt(i64(checked(21)))
    stdout.write("\n")
    return Ok(0)
}

There is no bare panic and no structured payload; a non-[*]u8 reason is a compile error. The runtime panics the same way on its own violated invariants (index out of bounds, for one).

recover

recover(reason) { … } intercepts a panic. It is legal only inside a defer body — the only moment a panic can be caught is while the unwind runs that frame's cleanup:

vyi
import { stdout } from "@io"

fn boom() i32 { panic "kaboom" }

fn safe() i32 {
    defer {
        recover(reason) {     // reason: [*]u8 — the panic's string
            return 7          // halts the unwind; safe() returns 7
        }
    }
    return boom()
}

fn main() !u8 {
    stdout.writeInt(i64(safe()))
    stdout.write("\n")
    return Ok(0)
}
  • The block runs only when the defer is running because of a panic; on a normal

or error return it is skipped (the rest of the defer body still runs).

  • reason binds the panicking panic's [*]u8.
  • A return in the block sets the function's return value and **halts the

unwind** — the caller sees an ordinary return.

  • A block that doesn't return merely observes: the unwind continues to the next

frame up, and crashes the process if nothing stops it.

recover is panic-only. An error travelling through try is a value — it runs defers but never a recover; handle it with catch (error model). Outside a defer, recover is rejected:

vyi-error
fn main() !u8 {
    recover(reason) { return Ok(1) }   // error: usable only inside a `defer` body
    return Ok(0)
}