Docs · Language

Expressions

This page catalogues every expression form: the primaries (literals, aggregates), the postfix chain (field access, calls, indexing), prefix operators, casts, the value-producing control-flow forms, and the failure-consuming operators try and catch. Guides 04 and 05 — Control flow introduce most of these by example; token-level syntax is in lexical.

Precedence and associativity

From loosest to tightest; equal-level binary operators associate left. Every binary and unary operator desugars to an @-method call — the mapping, derived operators, and overloading rules are the operators reference's territory.

LevelOperators
1.. ... (range)
2||
3&&
4| (bitwise or)
5^
6& (bitwise and)
7== !=
8< > <= >=
9<< >>
10+ -
11* / %
12prefix: - ! * (deref) & (address-of) try known
13postfix chain: .field [index] (call) — left to right

Comparisons don't chain (1 < 2 < 3 is (1 < 2) < 3, a type error), and bitwise operators bind looser than comparisons — write (flags & 4) != 0.

catch sits outside the table: everything after it, to the end of the expression, is the fallback (f() catch 5 + 1 falls back to 6). Parenthesise when a catch is part of a larger computation: (f() catch 5) + 1.

Literals

Integer, float, true/false, char, and string literals are defined in lexical. Two typing rules matter here: a numeric literal adopts the type its context asks for (defaulting to i32 / f64), and a literal that overflows its target is a compile error. A string literal evaluates to a [*]u8 view over its baked UTF-8 bytes.

Identifiers

A name evaluates to the binding it resolves to — a value, a function (functions are first-class values), or a type (types are known values usable as expressions). Resolution is lexical scope with shadowing; fn, type, and other declarations hoist within their scope, con/mut are visible from their line down (statements § blocks and scope).

Tuple and array literals

(a, b, …) builds a tuple; () is the unit value. One parenthesised expression is just grouping — a tuple has at least two elements (or none).

An array literal spells the type, then the elements: [N]T[e0, e1, …]. Empty brackets zero-fill:

vyi
import { stdout } from "@io"

fn main() !u8 {
    con pair = (40, true)
    con xs = [3]i32[10, 20, 12]
    mut zeroed = [4]u8[]        // all four bytes zero
    zeroed[0] = 9
    stdout.writeInt(i64(pair.0 + xs[2] + i32(zeroed[0]) - 19))
    stdout.write("\n")
    return Ok(0)
}

Struct literals

T{ field: value, … } builds a struct. Every field must be accounted for — named in the literal, or covered by a default from the declaration; there is no implicit zeroing. Fields are matched by name, in any order.

When the context already determines the struct type, _{ … } infers it — from an annotation, a parameter, a return type, a field:

vyi
import { stdout } from "@io"

struct Point { x: i32, y: i32 }

fn sum(p: Point) i32 { return p.x + p.y }

fn main() !u8 {
    con p = Point{ y: 2, x: 1 }      // any field order
    con q: Point = _{ x: 3, y: 4 }   // type inferred from the annotation
    con a = sum(_{ x: 5, y: 6 })     // from the parameter
    stdout.writeInt(i64(p.x + q.y + a))
    stdout.write("\n")
    return Ok(0)
}

A field name (like any name slot) can be a dynamic identifier [expr] — a known string computed at compile time (known values).

Enum construction

A variant constructs by calling it — with its payload as arguments, or bare-named when payload-less. The qualified form Enum.Variant(…) always works; the bare form Variant(…) works wherever the expected type at the use site already names the enum (annotations, arguments, returns, switch subjects). This is a general rule for every enum — Some/None/Ok/Err resolve exactly this way:

vyi
import { stdout } from "@io"

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

fn main() !u8 {
    con a = Shape.Circle(3)      // qualified
    con b: Shape = Rect(2, 3)    // bare: the annotation names the enum
    stdout.writeInt(i64(area(a) + area(b) + area(Dot)))
    stdout.write("\n")
    return Ok(0)
}

With no expected type there is nothing to resolve against — con o = Some(5) with no annotation is an error.

Field access

x.f reads field f; on a pointer it auto-derefs (p.f, never (*p).f, at any depth reached one . at a time). Tuple elements are positional fields: t.0, t.1, chaining as t.0.1. [*]T values carry .len and .ptr as fields. Cross-file access respects pub (guide 03).

A method is not a field: x.method(args) is a call form, and writing x.method without the argument list is an error — to pass behaviour around, use a named function's bare name or an arrow expression (guide 04).

Calls

callee(arg, …) — arguments are positional, evaluated left to right; a trailing comma is allowed, and an argument list may span lines after the open paren.

Defaults. A parameter declared with = expr may be omitted by the caller — trailing omission only, since arguments are positional:

vyi
import { stdout } from "@io"

fn bump(n: i32, step: i32 = 1) i32 { return n + step }

fn main() !u8 {
    stdout.writeInt(i64(bump(10) + bump(10, 5)))  // prints 11 + 15
    stdout.write("\n")
    return Ok(0)
}

Overload selection. Several functions may share a name if their full signatures differ; the call site selects by shape — arity first, then parameter types (the receiver counts as part of a method's signature). No single most-specific match is an ambiguity error, not a guess:

vyi
import { stdout } from "@io"

fn pick(x: i32) i32 { return x + x }
fn pick(a: i32, b: i32) i32 { return a + b }
fn pick(b: bool) i32 { return 1 }

fn main() !u8 {
    stdout.writeInt(i64(pick(7) + pick(10, 8) + pick(true)))  // prints 14 + 18 + 1
    stdout.write("\n")
    return Ok(0)
}

Overloads may also differ only in failure channel (fn parse(s) i32 beside fn parse(s) ParseErr!i32): a call consumed by try/catch prefers the fallible one, a bare call the plain one (error model).

Method calls resolve through the receiver: v.m(args) finds an m in scope whose receiver adapts to v's type — auto-ref for *mut/*con methods, load-copy for value methods on a pointer subject. Exact receiver shape wins when several apply. Resolution table and ranking: methods & dispatch.

Known arguments. A known parameter takes its argument at compile time; a call may pass it explicitly, pass _ to have it solved, or — when it's inferable from the runtime arguments — omit it entirely (known values):

vyi
import { stdout } from "@io"

fn max(known T: type, a: T, b: T) T {
    if a > b { return a }
    return b
}

fn main() !u8 {
    stdout.writeInt(i64(max(i32, 3, 7) + max(10, 20) + max(_, 1, 2)))  // prints 7 + 20 + 2
    stdout.write("\n")
    return Ok(0)
}

Calling a type like a function is not a call but a cast/conversion (below) — and calling a generic function that returns a type, like Box(i32), is an ordinary known call producing a type.

Indexing and slicing

xs[i] reads, xs[i] = v writes, xs[a..b] slices — desugaring to the subject's @index, @setIndex, and @slice methods, so any type can opt in (guide 13). On the built-in array shapes:

  • Indexing is bounds-checked; out of range panics at runtime.
  • Negative indices count from the end: s[-1] is the last element.
  • Slicing yields a [*]T view over the same storage — no copy. Either bound

may be omitted: s[..2], s[3..], s[..].

vyi
import { stdout } from "@io"

fn main() !u8 {
    con s = "hello"
    con mid  = s[1..3]     // "el"
    con last = s[-1]       // 'o' as a byte
    con all  = s[..]
    stdout.writeInt(i64(mid.len + all.len + i32(last) - 118))
    stdout.write("\n")
    return Ok(0)
}

Range expressions

a..b builds a half-open range (includes a, excludes b); a...b an inclusive one. A range evaluates to a Range value — an ordinary @core struct with start, end, and inclusive fields. The endpoints are stored as written and inclusive records the spelling, so a range means the same thing inline or bound to a name:

vyi
import { stdout } from "@io"

fn main() !u8 {
    con h = "hello"
    con direct = h[1...3]    // "ell"
    con r = 1...3            // Range{ start: 1, end: 3, inclusive: true }
    con stored = h[r]        // also "ell" — the value carries its meaning
    stdout.writeInt(i64(direct.len + stored.len))  // prints 3 + 3
    stdout.write("\n")
    return Ok(0)
}

for x in 1...5 iterates 1 through 5. A range stored in a binding is a plain struct value and is not itself iterable (guide 05). Range types (0..101 in type position) are covered in types § singleton types.

Casts

A cast is the target type applied like a function: TargetType(x). Casts are the only implicit-conversion-free zone's escape: nothing numeric converts on its own, so every representation change is written down.

CastBehaviour
int → inttruncates to the target width at the cast site (a runtime 300 cast to u8 is 44)
float → intdrops the fraction (i32(2.9) is 2)
int → float, f32f64numeric conversion
char ↔ integercodepoint value ↔ number
bool ↔ integertrue is 1, false is 0
struct → structthe shape-compatible copy, explicit form (types)
array shapesview/copy conversions between [N]T, *[N]T, [*]T (types § arrays)
vyi
import { stdout } from "@io"

fn main() !u8 {
    con big: i32 = 300
    con t = u8(big)          // 44 — truncated now, not at some later use
    con n = i32(2.9)         // 2
    con c = u32('A')         // 65
    stdout.writeInt(i64(n + i32(t) - i32(c) + 19))
    stdout.write("\n")
    return Ok(0)
}

A literal that overflows is a compile error; a cast of a runtime value truncates — you wrote the cast, so the narrowing is deliberate. Casting an aggregate (a struct, enum, or tuple) to a primitive is rejected — read a field or convert explicitly. Casting a pointer to an integer yields its address, which is occasionally needed at the FFI boundary (guide 15); an integer never casts back to a strict pointer.

Prefix operators

FormMeaning
-xnegation — desugars to x.@neg()
!bboolean not; b must be bool (integers are not truthy)
&xaddress-of: *mut T from a mut binding, *con T from a con
*pdereference; also the write target in *p = v
try eunwrap-or-propagate (below)
known eforce compile-time evaluation (known values)

& reaches interior places too: &pt.x, &arr[i]. Deref stacks (**qq) one level per star.

typeof, sizeof, alignof

Three built-in operators over types, usable with or without parentheses — sizeof T and sizeof(T) are the same expression:

  • typeof expr — the type expr would have; the operand is type-checked but

never evaluated. Over a union-typed operand in value position it asks which member is live — a runtime question (types § unions).

  • sizeof T — the storage size of a T value in bytes, as an i32 known value

(sizeof u8 is 1; sizeof S.field sizes the field's type in the struct) (padding included; see the storage note in types).

  • alignof T — the alignment of T in bytes, as an i32 known value.
vyi
import { stdout } from "@io"

struct Header { tag: u8, len: i32 }

fn main() !u8 {
    con size = sizeof Header
    con al = alignof(Header)
    con n = 5
    type N = typeof n            // i32 — n is not evaluated
    con m: N = 30
    stdout.writeInt(i64(size + al + m - size - al + 12))
    stdout.write("\n")
    return Ok(0)
}

shapeof — pure pattern match test

Pattern shapeof Subject is a boolean expression: true when Subject matches the refutable Pattern, false otherwise. The pattern sits on the left, same as if Pattern = Subject. Binders are illegal — shapeof only tests shape; destructure with if:

vyi
import { stdout } from "@io"

fn main() !u8 {
    con o: ?i32 = Some(5)
    con hit = Some(_) shapeof o     // true
    con miss = None() shapeof o     // false
    // Some(v) shapeof o            // error: no binders in shapeof
    if Some(v) = o {
        stdout.writeInt(i64(v))  // bind with if
        stdout.write("\n")
        return Ok(0)
    }
    return Ok(0)
}

Discards (_, Ok(_), None(), nested wildcards) are allowed. Precedence is at the comparison level (==), so Some(_) shapeof o && flag groups as expected.

if and switch as expressions

Both statement forms (statements) work in value position, where each branch yields its block's final expression.

An if expression must produce a value on every path, so else is required:

vyi
import { stdout } from "@io"

fn main() !u8 {
    con n = 2
    con a = if n == 1 { 100 } else if n == 2 { 200 } else { 30 }
    stdout.writeInt(i64(a - 158))
    stdout.write("\n")
    return Ok(0)
}

con v = if n > 0 { 1 } is rejected with "if-expression requires an else branch." (One exception falls out of compile-time evaluation: an if whose condition is a known value folds — the live branch is selected and the dead one never exists, so the folded form needs no else. See known values.)

The if Pattern = subject binding form works in value position too (patterns).

A switch expression yields the taken arm's value. Single-expression arms need no braces, and a one-line switch has no trailing comma; an arm that diverges (return, break, panic) simply doesn't produce the value:

vyi
import { stdout } from "@io"

fn main() !u8 {
    con n = 2
    con label = switch n { 1: 10, 2: 20, any: 99 }
    con checked = switch n {
        0:   { return Ok(1) },   // diverging arm
        any: { n * 10 },
    }
    stdout.writeInt(i64(label + checked))  // prints 20 + 20
    stdout.write("\n")
    return Ok(0)
}

Exhaustiveness and the arm grammar are shared with statement switch (statements § switch, patterns).

Block expressions and break with a value

A bare { … } block in value position evaluates to its final expression. Inside it, break value yields early, and labeling the block (#name { … }) lets break #name value yield from anywhere inside — including from a loop nested within the block:

vyi
import { stdout } from "@io"

fn firstSquareAtLeast(n: i32) i32 {
    con found = #search {
        mut i = 0
        for i * i < 1000 {
            if i * i >= n { break #search i * i }
            i = i + 1
        }
        -1
    }
    return found
}

fn main() !u8 {
    stdout.writeInt(i64(firstSquareAtLeast(8)))  // prints 9
    stdout.write("\n")
    return Ok(0)
}

Value position means a spot that consumes the value — an initialiser, an argument, an operand. A block in statement position (including directly under return) is a plain statement block and takes a plain break.

Only a value-position block yields a value. A loop never produces one, so break with a value targeting a loop is rejected:

vyi-error
fn f() i32 {
    for {
        break 5   // error: a loop is not a value-position block
    }
    return 0
}

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

There is also a compile-time block form, known { … }, whose tail expression becomes a known value (known values).

Arrow functions

(params) Ret => body is an inline function value: a parameter list, an optional return type, =>, then a single expression or a block. Parameter and return types may be _ (solved from the expected fn(SIG) type). Declaration-style fn name(...) {…} and unnamed fn() T {…} are not expression forms — only arrows create function values in expression position; fn(SIG) is the function type spelling.

vyi
import { stdout } from "@io"

fn apply(g: fn(i32) i32, x: i32) i32 { return g(x) }

fn main() !u8 {
    con add = (a: i32, b: i32) i32 => a + b        // expression body
    con thunk = () i32 => 42                       // zero parameters
    con id = (x: i32) => x                         // return type inferred
    con dbl = (a: i32) i32 => {                    // block body
        return a * 2
    }
    con h: fn(i32) i32 = (x: _) _ => x + 1         // holes solved from the annotation
    con viaParam = apply((y: _) i32 => y - 1, 10)  // hole solved from apply's parameter
    stdout.writeInt(i64(add(1, 2) + thunk() + id(3) + dbl(4) + h(5) - viaParam - 53))
    stdout.write("\n")
    return Ok(0)
}
vyi-error
fn main() !u8 {
    con x = fn() i32 { return 1 }   // error: use `() i32 => 1` (or a block arrow)
    return Ok(0)
}

Arrows capture enclosing con bindings by value at creation; capturing a mut is a compile error, and captured pointers must be stable — the closure rules are in guide 04.

try and catch

The two consumption operators for failure-shaped values (E!T, !T, ?T). They are ergonomics over ordinary enums — switch and if-binding always work too.

try expr unwraps the success case; on failure it returns the failure from the enclosing function right there. The failure must fit the enclosing function's own channel (a Result through a Result-returning function, an Optional through an Optional-returning one — crossing channels needs catch):

vyi
import { stdout } from "@io"

enum MathErr { DivZero }

fn div(a: i32, b: i32) MathErr!i32 {
    if b == 0 { return Err(DivZero) }
    return Ok(a / b)
}

fn avg(a: i32, b: i32) MathErr!i32 {
    con s = try div(a + b, 2)     // on Err: avg returns that Err
    return Ok(s)
}

fn main() !u8 {
    stdout.writeInt(i64(avg(40, 44) catch 0))
    stdout.write("\n")
    return Ok(0)
}

try works in statement position (propagate, discard the Ok) and nested in larger expressions — return Ok(try f(x)) is a common tail.

expr catch fallback unwraps the success case; on failure it produces the fallback. What follows catch picks the form — a value, a block whose tail is the fallback (or which diverges), or a handler called with the error:

vyi
import { stdout } from "@io"

enum MathErr { DivZero, Overflow }

fn safeDiv(a: i32, b: i32) MathErr!i32 {
    if b == 0 { return Err(DivZero) }
    return Ok(a / b)
}

fn onErr(e: MathErr) i32 {
    switch e {
        DivZero:  { return -1 },
        Overflow: { return -2 },
    }
}

fn main() !u8 {
    con a = safeDiv(40, 4) catch 0                       // value: 10
    con b = safeDiv(9, 0)  catch { con d = 20; d + 1 }   // block tail: 21
    con c = safeDiv(9, 0)  catch onErr                   // handler fn: -1
    con d = safeDiv(9, 0)  catch (e: MathErr) i32 => 7   // arrow handler: 7
    stdout.writeInt(i64(a + b + c + d - 36))  // prints 1
    stdout.write("\n")
    return Ok(0)
}

On a ?T there is no error value, so catch supplies the fallback for None and a handler form takes no argument. The precise semantics — the same-channel rule, error-set inference through try, channel crossing — are the error model reference; guide 07 is the walkthrough.