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/export—import { 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
con limit = 42 // immutable, type inferred
con pi: f64 = 3.14159 // explicit type
mut total = 0 // mutable — reassignment allowed
total = total + limitEvery 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:
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
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:
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:
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:
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
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 binding — if Pattern = subject { … } —
whose binders are in scope in the then-block, with an optional ; guard condition
after the subject:
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:
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 arms —
Red, Green: { … }shares one body between
alternatives (which therefore can't bind payloads).
- Guard chains — there is no per-arm guard;
switch truewith condition arms
covers it (guide 05).
Missing the comma is a parse 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:
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:
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:
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:
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
deferregisters 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.
returninside 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.
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:
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).
reasonbinds the panickingpanic's[*]u8.- A
returnin 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:
fn main() !u8 {
recover(reason) { return Ok(1) } // error: usable only inside a `defer` body
return Ok(0)
}