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.
| Level | Operators |
|---|---|
| 1 | .. ... (range) |
| 2 | || |
| 3 | && |
| 4 | | (bitwise or) |
| 5 | ^ |
| 6 | & (bitwise and) |
| 7 | == != |
| 8 | < > <= >= |
| 9 | << >> |
| 10 | + - |
| 11 | * / % |
| 12 | prefix: - ! * (deref) & (address-of) try known |
| 13 | postfix 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:
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:
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:
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:
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:
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):
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
[*]Tview over the same storage — no copy. Either bound
may be omitted: s[..2], s[3..], s[..].
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:
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.
| Cast | Behaviour |
|---|---|
| int → int | truncates to the target width at the cast site (a runtime 300 cast to u8 is 44) |
| float → int | drops the fraction (i32(2.9) is 2) |
int → float, f32 ↔ f64 | numeric conversion |
char ↔ integer | codepoint value ↔ number |
bool ↔ integer | true is 1, false is 0 |
| struct → struct | the shape-compatible copy, explicit form (types) |
| array shapes | view/copy conversions between [N]T, *[N]T, [*]T (types § arrays) |
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
| Form | Meaning |
|---|---|
-x | negation — desugars to x.@neg() |
!b | boolean not; b must be bool (integers are not truthy) |
&x | address-of: *mut T from a mut binding, *con T from a con |
*p | dereference; also the write target in *p = v |
try e | unwrap-or-propagate (below) |
known e | force 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 typeexprwould 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 aTvalue in bytes, as ani32known 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 ofTin bytes, as ani32known value.
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:
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:
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:
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:
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:
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.
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)
}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):
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:
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.