Skip to content

Specification §14

Syntax and conventions

language-design.md §14 · 241 lines · 28 min read

The whole syntax is justified by five cross-cutting rules, and each spelling decision falls out of one of them, not of taste:

  1. The default is the unmarked. The common and safe case never takes a keyword; only extra capability or danger is marked. It holds for mutability (let/bare := vs var/mut/*T/@transfer), visibility (pub public, priv private-to-file, private-to-module bare) and compilation (comptime marks; runtime is bare).
  2. Concept = word, operation = symbol. If it structures the program, it is a keyword (fn, match, loop); if it is an action at a point, it is a symbol (<-, ., :=).
  3. Orthogonal modifiers compose. One prefix applied to several things, instead of one keyword per combination: pub fn, comptime fn, @generator fn, comptime var.
  4. Same idea, same look. Conceptually equal constructs look alike, and that is why match absorbs switch, select and the branch over a Result’s error (section 7), loop absorbs for, while and infinite, and overloading is just match over a type (section 13).
  5. Honest name: says what it returns and what can fail. The name delivers the result and the edge consequence, without a decoder ring: no innocuous name hiding a panic (the unwrap anti-pattern, renamed or_panic, which shows the consequence), nor a borrowed term that does not exist in the language (is_some became is_present, since there is no some). It is “no hidden control flow” applied to names: or_panic/or(default) instead of unwrap/unwrap_or; get -> Optional (“maybe”) distinct from at -> T (“I guarantee it”); equals instead of the abbreviated eql. Universal and honest vocabulary (push/read/map) or precise domain vocabulary (seal/open of AEAD, which carries the “authenticated” that encrypt would lose) stays; the misleading one does not.

let/var/const, with the := shortcut; immutable is the default (see section 6):

Form Meaning
x := v immutable, runtime (shortcut, common case)
let x := v immutable, runtime (explicit)
var x := v mutable, runtime
const x := v compile-time constant (= comptime let)

Explicit type Odin-style (x: T = v); := is : = with the type omitted. The rule: := (or : T =) declares; a bare = only assigns to a var, since the : marks a declaration. Discarding a return is explicit: _ := expr (the _ is the absent-variable, section 28); dropping a value on the floor without _ := is an error, because discarding is an intent, and an intent is written. comptime is the orthogonal modifier of “evaluate at compile time” (comptime fn/let/var/loop/match/{}; see section 10).

Multiple binding and tuple. Several values at once form a tuple (a, b) (section 7): coords := (10, 20) binds the tuple to a name. To bind to several names (parallel assignment, or destructuring a tuple-return) both spellings hold and match by position: a, b := f() (loose names) or (a, b) := f() (with the tuple’s parentheses). Swapping two values is a, b = b, a.

for, while and the infinite loop differ only in the header, not in the body, so there is one keyword, loop, and the header decides the form:

loop { } // no header → infinite
loop cond { } // boolean expression → while
loop x in xs { } // IDENT in iterable → for-each (collection, generator, range)
loop i in 0..8 { } // exclusive range → counted (..= is inclusive)
loop i := 0; i < 8; i = i+1 { } // C-style; the ';' delimit the 3 parts (parentheses optional)

break/continue work the same in all of them. In the C-style the parentheses are optional (Go style): on a single line, the explicit ; already delimit the three clauses (automatic ; insertion only acts on a line break, and here there is none), and the { closes the header. The parentheses are only necessary if you break the header into several lines (or to isolate a composite literal in the header, as in Go).

One construct for “given a value, choose a branch”, which absorbs what other languages split into switch/match/select, plus the branch over a Result’s error (section 7). The compiler distinguishes three forms by the token right after match: a subject matches patterns (the “switch”); { opens channel operations (the “select”); |e| hung on an op branches the error of that operation:

// with a subject → matches patterns (the "switch"/"classic match")
match result {
Ok(v) => use(v)
Err(e) => handle(e)
}
match code {
200 | 201 | 204 => ok() // or-pattern with '|' (covers the fallthrough case)
404 | 410 => gone()
_ => other() // default is always an explicit '_'
}
// without a subject (only '{') → matches ready channel operations (the "select"; see section 4)
match {
v := -> chan_a => handle(v)
_ := -> chan_b => signal() // '_ :=' discards the received value
} timeout(5s) { // timeout is a trailer of the select; timeout(0) = non-blocking
expired()
}
// trailer '|e|' on an op → branches the error of a fallible operation (the Ok becomes the variable; section 7)
v := do_something(x) match |e| {
E1 => { log(e); return Err(e) } // value-binding: the arm escapes (logs, propagates)
E2 => runtime.panic(e) // or aborts
}

The select (match {}) accepts a timeout(...) as a trailer: the block runs if no channel responds within the deadline; without it, the select waits indefinitely, and timeout(0) is the non-blocking poll. The form op match |e| is the one that hangs the match on an operation: the subject (the error) comes by position and the |e| names it. It only holds with a union error; a single variant is a compile error (use catch; section 7).

Exhaustiveness: in a sum type or enum (closed domain) the compiler requires all the branches and forbids the _. A _ in an exhaustive match is a compile error, because it would nullify the safety net: adding a variant would silently fall into the _ instead of breaking the build until you handle it (the same ruler as “capability declared and not exercised is an error”, section 6). In an open domain (int, string) the _ is mandatory, being the default-case (there is no default keyword; the _ plays that role), required because the value’s domain has no closed scope. There is no fallthrough; the or-pattern | covers “several values, same branch”. The arms separate by line break (not by comma), one per line; on a single line, they separate by ; (the statement terminator, as in unsafe { a; b }), never by comma. The comma is left for value groups (arguments and tuples in (), type-level sets, and array/slice literals []int{1, 2, 3}); bodies of distinct members (decl fields and variants, struct literals Vec2{1; 2}, match arms) separate by ;. The separator follows the content, not the bracket: a struct literal and an array literal both use { }, but the struct’s fields are distinct members (;) while the array’s elements are a value group (,).

The language has no implicit return. A block does not evaluate to its last expression; the => of an arm does not deliver the right side, since it only separates pattern and body, and the body (like the arms of the channel select) runs logic. To produce a value or leave, always explicit: return, break, panic.

return X has a single meaning, decided by the position of the construct:

  • expression (the construct produces a value): return X delivers X to the place that receives the expression. v := match f() { Ok(x) => return x } gives x to v; x := loop { ... return v } gives v to x. This holds for match and for loop; if is a statement, so a value chosen by a condition is the ternary (v := c ? a : b).
  • statement (runs loose): return X is an early-return, that is, it leaves the function. if c { return err } propagates; f() match |e| { Timeout => return e } leaves.

The arm or branch inherits the position of the construct; there is no per-arm rule. panic aborts; break leaves the loop (see below); both always diverge.

loop as a value. A loop may stand in value position (x := loop { ... return v }), delivering by an explicit return the same way a value-position match does. This is what if deliberately does not have. A value-position loop must deliver on every exit path (like a function body): an infinite loop { ... return v } needs a reachable return; a conditional/for loop must return on every path of its body, or the checker reports the path that can finish without a value. (A body that always returns still needs at least one iteration; the empty-collection / immediately-false case is caught at runtime.) A loop that only iterates for effect is a plain statement, unchanged. This coexists with the functional path (xs.reduce(...), a generator’s .collect()): those transform a sequence that already exists, while a value-position loop computes a value from scratch.

break and continue: no value, optional label. break leaves a loop and continue skips to its next iteration; neither carries a value (a loop delivers its value by return, so break that would leave a value-position loop is an error). A bare break/continue targets the innermost loop; a label targets a named enclosing loop, written Go-style before the loop:

outer: loop x in xs {
loop y in ys {
if found(x, y) { break outer } // leave both loops
if skip(y) { continue outer } // next x
}
}

A label is only a loop’s name (outer:); it is distinguished from a typed binding (x: T) by the loop keyword that follows the colon.

Distributed return (sugar). A match in a value position would require return in each arm, which is verbose. The shortcut: return match ... distributes the return to the arms. return match s { Idle => Running } equals match s { Idle => return Running }. The two forms coexist; choose by reading. (In a value-binding there is no outer return to distribute, so the arms take the return: v := match f() { Ok(x) => return x }.) The => still does not deliver on its own: the delivery always lives in an explicit return, in the arm or in the one that distributes. if does not take part in this: if is a statement, never a value, so to choose between two values by a condition you use the ternary below, not an if.

To choose between two values by a condition, the ternary cond ? a : b, an expression:

icon := liked ? "♥" : "♡"
max := a > b ? a : b

It is sugar for the binary choice that match would make verbose (match cond { true => return a; false => return b }). It holds only for two alternatives; three or more is match (a nested ternary becomes soup, and the match reads better).

An anonymous function passed inline to another function. Two forms, both only in argument position:

xs.filter(fn(l) => l.contains("ERROR")) // short form: one expression, '=>' delivers
xs.map(fn(l) { parse(l); return enrich(l) }) // body form: several lines, explicit 'return'

In a one-expression lambda, the => delivers the right side, and this does not contradict the => of match (which does not deliver), because the two are different things: a lambda is, by definition, a value producer (it exists to compute a result), while a match arm is a branch (it does something in that case). fn(x) => e is just sugar for fn(x) { return e }; the => delivers because the lambda’s body is the return. At bottom (philosophy, section 1), this => is the explicit exit marker (the analog of return, of the same family as the ternary’s ?:), so the short form does not violate no-implicit-return: you do not guess that e is the exit by position (“it is the last expression”), you read it in the =>. The price is the => glyph having two roles (exit in the lambda, separator in the arm), accepted because the two never cross (in the lambda the => is followed by an expression; in the arm, by a body) and “maps to” is the intuition common to both. A lambda’s return type is inferred from its body, but you may also write it explicitly — fn(x: int) -> int => x + 1 or fn(x) -> int { … } — for a signature you want pinned at the call site; both the inferred and the annotated forms are valid.

A lambda is only a shortener of an anonymous function, it does not replace a declaration. The two forms hold exclusively as a direct argument of a call. Binding to a variable (f := fn(x) => …), returning, storing in a struct, or using at the top of the file is a compile error; for a named, reusable or top-level function, it is fn name(...) { ... }. The fence is purposeful: it cuts at the source the const f = x => … scattered around (JS’s arrow functions become a variable name in every corner); here, an anonymous one does not bind and does not persist, and whoever wants to reuse it gives it a name. This fence is the rule of the transient scope (section 6): a lambda is a transient expression, which exists only crossing a call’s boundary, never loose.

The fence covers the anonymous function, not the named one, and it is not a fence against naming a scope. Two consequences follow, and they pull in opposite directions on purpose.

A named function does not bind to a variable either — g: fn(int) -> int = double is a compile error. Not because it would leak scope (a named function closes over nothing), but because it is renaming: double already has a name, and the way to call it something else is to change its name. A binding that only aliases an existing function buys nothing and costs a second name for one thing. Want to return a function, or reuse one? Return it, or call it, by its name. (This resolves the apparent opening of “or a binding of a function type” in section 6: what holds a function is a parameter of function type — the callback that receives it — not a local binding.)

A function may be declared inside another function. fn a() { fn b() { ... } } is valid: b is visible only inside a’s body (shadowing a top-level b of the same name), it can be called before its declaration, siblings can call each other, and it can recurse. It does not make every helper global just because it is used once — not every function deserves module scope. But nesting is scope, not capture: b does not see a’s locals; everything it needs arrives through its parameters. Closing over a scope remains exclusive to the lambda, which pays for that power by being transient. So the nested function is the answer to “I need a helper here”, and the lambda is the answer to “I need this recipe, right now, over these values” — and neither becomes JS’s floating arrow function.

Deterministic cleanup in any function or block; it runs on scope exit (including an early return), multiple defers in LIFO. It is the release mechanism for @mm(none)/manual:

fn read_config() -> Result[Config, error] {
f := open("cfg") catch |e| { return e }
defer f.close() // runs on exit, in any path
...
}

The arguments are frozen at the defer; only the call is postponed. defer f.close() closes the f of right there: the receiver and every argument are evaluated when the defer is reached, and the call runs later with those values. That is the whole point of the construct — postpone this call site, with its inputs in this state. Reading the arguments later would silently retarget the cleanup: reassign f after the defer and you would close the wrong file, which is exactly the bug defer exists to prevent. A cleanup that must observe a later value is not a deferred call at all — move the call to the end of the scope and pass what you want. Freezing an aggregate freezes its value, like any other by-value passing (§6), so a later p.x = 9 is not visible to the cleanup.

var n := 0
defer show(n) // frozen: shows 0
n = 9 // ... not 9

timeout is a keyword: the mandatory deadline of the <-> (section 4, where it becomes error.Timeout in the Result) and the trailer of the subjectless match (the block that runs if no channel responds within the deadline). The value is a Duration (builtin type, u64 nanoseconds; a duration is never negative), written with lexer unit literals: 5s, 300ms, 10ns, 6m, 7h, 18d, literal notation (like 0xFF or 1_000), converted by the lexer to Duration, without importing anything. timeout(_) is the infinite deadline (explicit, no deadline).

reply := chan <-> req timeout(5s) // deadline on <-> → error.Timeout in the Result
match { ... } timeout(100ms) { ... } // deadline on the select → trailer with a block (section 4)
chan <-> req timeout(_) // <-> without a deadline (infinite, explicit)

The time package (stdlib) brings rich arithmetic and arbitrary-precision durations (Duration[T]/ArbitraryDuration over any integer, like picoseconds); the builtin Duration of timeout is the u64-ns, and whoever needs more imports time (pure opt-in).

A single sigil, @, for everything that talks to the compiler. The rule is whether there is configuration:

  • A directive with an argument uses parentheses: @mm(arena), @limit(16mb), @supervisor(strategy: rest_for_one), @promote(gc, deep) (postfix on the value: result @promote(gc); see section 5).
  • A pure tag does without parentheses: @transfer (postfix on the value: chan <- result @transfer), @generator (prefix on fn).

@transfer replaces the old notation #(...); @generator fn replaces the keyword generator. (There is no @shared; see section 5.)

The constructs that mark and contain memory danger (full semantics in section 5). unsafe is an orthogonal modifier (Principle 3), like pub/comptime/@generator; assert/assume are the containment trailer of an unsafe operation, mirroring catch in form but not in semantics:

  • unsafe marks the region or operation where the guarantees are suspended, in four forms: unsafe expr (single operation), unsafe { } (group), unsafe fn (the function passes danger to the caller), unsafe union (marked declaration). In a fn, unsafe is the forced consequence of there being an unkilled unsafe operation in the body, not a spelling choice; killing everything with assert/assume in the body gives a normal fn. The function modifiers go in pub? comptime? unsafe? fn; @generator is a decorator like the others (above the function or inline), with no fixed slot.
  • assert/assume are the handlers, always a trailer of an operation, never in the header (they carry logic, not a bit). Two keywords, one intent each: assert (cond) checks, and the runtime verifies and panics if false; assume "reason" trusts, a documented assertion without a check (and without a license for UB). Not killing is passing along (the function becomes unsafe fn).
  • @requires(cond) and the post-condition (in the return type) are the function’s boundary contract: design by contract, @ directives (zero keyword), distinct from the operation assert/assume. The compiler checks them at each call and return: static where it proves, panic where it does not. A checkable and contracted pre-condition makes the function safe to call (the caller does not re-declare; it is the layer that builds safe over unsafe). Multiple @requires stack; predicate reuse is an ordinary boolean function (no contract construct, which would not pass the opt-in test, section 1).
b := unsafe raw_read(buf.ptr + i) assert (i < buf.len) // strong: checks, panic if false
y := unsafe x.bits assume "float→bits" // weak: asserts, no check
unsafe { p.next = q; q.prev = p } assert (p != q) // block trailer

use is the only import keyword: it brings in the module (qualified access), specific names, or an inline call outside the top:

use json // assembles the module → json.parse(...)
use json.{parse, decode} // lifts names → parse(...) directly
use json as j // alias
use json.parse(data) // inline: a one-off call, without bringing anything into the file's scope

(The complete system, with unit, path resolution, visibility, packages, comptime and initialization, is in section 15. Here there is only the use keyword.)

  • Integers Zig-style, with arbitrary bit width from 1 to 65535: iN/uN for any N in that range, where i3, u7, u777 and i65535 are valid. It is not a fixed list, it is a parametric family; i8/u8/i16/u16/i32/u32/i64/u64/i128/u128 are just its common cases. isize/usize for pointer width; int/uint as aliases of the default width; byte = u8, bit = u1. A non-multiple-of-8 width behaves with the declared width (the arithmetic of a u3 is checked against its 3-bit range) but occupies the next addressable size (a loose u3 occupies 1 byte; an element of []u3/[*]u3 also occupies 1 byte each, addressable and indexable, at the cost of 5 wasted bits; it occupies the real 3 bits only inside a packed struct). You do not take a *T of a sub-byte field (neither loose nor of a packed one), and the reason is that memory is byte-addressed: a normal pointer holds a byte address, and there is no way for it to name “the 3 bits at offset 3 inside this byte”. Allowing it (as Zig does) would require a separate bit-pointer type, which carries the offset and does not interoperate with a normal pointer, that is, two kinds of pointer in the type system. We chose a single kind (simpler); if the need arises, that bit-pointer can be added then. At the C boundary (@repr(c)/@extern, section 23), a width with no defined C ABI (u3, since C has no u4) is a compile error, with no silent widening; to cross it you explicitly choose a C-compatible type (u8).

    Overflow traps by default. +, -, * panic at runtime when the true result leaves the width’s range (checked arithmetic, like Zig and Rust in debug), which is what makes a width a real invariant instead of a silent truncation. A constant expression that overflows is a compile error: the checker folds x: u8 = 200 + 100 and rejects it, it does not wait for runtime. When you want modular arithmetic you ask for it at the operator: +%, -%, *% wrap two’s-complement, so a wrap is always visible in the source rather than implied. The width conversions that lose information (300 as u8, 3.9 as i32) go through the explicit as cast for the same reason. The bitwise shift << is the exception, and deliberately so: a shift is a bit operation on the width, not an arithmetic count, so bits shifted past the top are masked to the width ((19: u8) << 4 is 304 mod 256 = 48), the C/hardware meaning of a shift. It does not trap and there is no <<%, because a shift out of the width is the operation working as defined, not an overflow of a magnitude. (Use * / *% when you mean “multiply by a power of two” and want the trap / wrap magnitude semantics.)

  • byte and bit: convention, not rule. An honest caveat, in the same spirit as the string unit (section 16) and the byte of the C-FFI (section 23): by definition, byte does not have a fixed size. Throughout history it has been 3, 4, 6, 7 and 9 bits, always “a group of bits”, never a prescribed size; the octet (8 bits) is the convention that won, not a law of computing. The language adopts that convention (byte = u8) because every target it compiles to is an octet and because the alternative, a generic byte[N], would destroy the ergonomics of the size literals without gaining any power. No power because “a group of N bits” is already the uN: arbitrary bit precision is the iN/uN family (above), and whoever wants 3 bits writes u3, not byte[3]. So byte is just the name of the octet (the storage atom, the element of []byte, the IO unit) and bit is just the friendly name of u1; the width lives in the type (uN), and the two are aliases of cases of it. (On a hypothetical non-octet target, matching the octet to the native unit would be the work of that target’s MemorySource (section 5), not of the byte type, which remains the universal convention.)

  • Floats in the IEEE widths: f16/f32/f64/f128/f256 (plus bf16 for ML); float as the default alias. The fixed family stops where IEEE 754 stops naming a format of real use: the standard defines binary16/32/64/128/256 (and, in principle, any binary{32k} past 128, which nobody uses), so the language ships those five and nothing in between. Arbitrary width is not a fixed float the way uN is a fixed integer, because there is no canonical format nor hardware for it: a wider float has to pick exponent bits versus mantissa bits, a choice the integer side never faces (a group of N bits is already uN). When you genuinely need more than 256 bits of float, you reach for BigFloat below, which is a different tool with a different cost.

  • A float is always a finite real number: NaN is an error, and overflow traps like an integer overflow does. This is the float side of the same honesty that makes + trap on integer overflow. NaN is literally not a number: an operation that would produce it (0.0/0.0, inf - inf, 0 * inf, the square root of a negative) is a runtime error, never a value that propagates silently. (Silent NaN propagation is exactly the hidden-unknown the language spends effort banishing, sections 1 and 14.) Overflowing the width does not yield inf either: it traps, the same as i8 overflow, so a float you hold is guaranteed finite and comparable. The payoff is a clean order: with no NaN and no inf, every float is totally ordered, so < returns a plain bool and there is no need for the two comparison functions (a partial < and a total isless) that languages keeping IEEE NaN are forced into. Real, directed infinity (a limit, projective work) is a deliberate, separate opt-in ExtendedFloat (math, the Mathematica model: +inf/-inf are definite directed values, while a genuinely undefined 0/0 is still an error, never an inf). You ask for ExtendedFloat when the science needs the point at infinity; the everyday float stays finite by default, because inf-by-default is precisely the value that starts standing in for “unknown”.

  • Comparison: a total order for the finite, a partial order only for the rigorous ball. Because a float is always finite (above), and int/Decimal/BigInt/BigFloat are exact, all of them carry a total order: < > <= >= == != return a plain bool, and the shared Ordering { Less; Equal; Greater } (a builtin enum, like Optional and Result, since the result of so fundamental an operation should not be gated behind an import) is the result of a three-way .cmp(), the type a user type implements to become orderable (the Comparable behavior; <=/>=/!= are derived from it, not separate primitives). The one exception is BigFloatArb (the error-bounded ball): two balls whose intervals overlap have no decidable order (the true values could fall either way). It would be a lie for < to return bool there, so the ball has no comparison operators; it has a method .cmp(b) -> ArbOrdering, where ArbOrdering { Less; Equal; Greater; Indeterminate } adds the fourth case, and the exhaustive match forces you to confront Indeterminate (raise the precision and retry, or decide it yourself). This is the standard total-order-versus-partial-order split (Rust’s Ord vs PartialOrd), and it stays contained: Indeterminate is a definite enum variant local to the ball’s result, exactly as Optional.none is a definite variant, not a value of “unknown” leaking into the type system. A three-valued boolean is rejected on purpose: a bool that can be “maybe” is an unknown smuggled into the one type whose entire job is certainty, and it would poison every &&/||/if with three-valued logic (tainted coloring, section 1). The uncertainty of interval arithmetic lives in one enum variant of one method’s result, and nowhere else.

  • Arbitrary precision lives in math (stdlib), not in the global scope, yet reads like ordinary arithmetic. This is the deliberate resolution of two pulls: the non-poisoning rule keeps these names out of every program’s scope (you never pay for BigInt in code that does not name it, the opt-in test), while the operator privilege (Conventions, below) blesses them as core-numeric so they use + - * / like any number, never a.add(b). A library type could never get the operators (no overload, section 13); that is precisely why arbitrary precision is core-blessed rather than a plain library. Five types, each a distinct tool:

    • BigInt: an unbounded integer, no width parameter. It does not trap, it grows (sign plus a slice of limbs), which is the clean conceptual opposite of the trapping iN. For exact counting, cryptography, number theory.
    • BigFloat[M]: an arbitrary-precision floating number with M mantissa bits of working precision, correctly rounded each operation (the MPFR model). Precision is relative: M significant bits wherever the value sits on the number line. This is the scientific-computing workhorse (very large or very small magnitudes, special functions, constants).
    • BigFloatArb[M]: the same M-bit float carrying a rigorous error bound (the Arb/FLINT model: a midpoint and a radius), so after a chain of operations you know which digits survived. It costs more (every operation tracks the bound) and you ask for it when you need certainty rather than a number that merely looks precise (catastrophic cancellation you must detect, results that back a proof).
    • Decimal and Decimal[P, S]: exact base-10 numbers, for money and accounting. The base is the whole point: 0.10 is exact in base-10 and a repeating fraction in base-2, which is why currency never rides a binary float. Decimal is arbitrary-precision (a BigDecimal, with the rounding made explicit at a division); Decimal[P, S] is the bounded form (P total significant digits, S decimal places, the shape of SQL’s DECIMAL(p, s)) for when you want the size pinned in the type. “Rounding made explicit at a division” is literal and enforced: when a Decimal / Decimal has no exact result (a repeating expansion, e.g. 1 / 3), the precision is the programmer’s decision — you supply it (a rounding/precision context on the division, or a bounded Decimal[P, S] result type). An unbounded division must not silently pick a hidden default precision (there is no “34 digits and hope”), and it must not silently drop or approximate the value either — because Decimal exists precisely for money, accounting and SQL, where how many places you keep is a domain choice, never a library guess.

    Narrowing precision is always explicit, for both families. The same discipline governs assignment across precisions: moving a value to a smaller precision — a Decimal[10, 4] into a Decimal[10, 2] (fewer decimal places), or a BigFloat[256] into a BigFloat[64] (fewer mantissa bits) — is lossy and must be written with an explicit conversion (as, or a rounding context), never performed implicitly by a plain assignment. A declared target type does not silently license the loss: let b: Decimal[10, 2] = a where a is a Decimal[10, 4] is a type error asking for the explicit narrowing, exactly as the BigFloat cross-M assignment already is. Widening (into more precision) stays free, since it loses nothing. This keeps the two arbitrary-precision families consistent and the loss of digits always a decision you can see, not one the compiler makes for you.

    Why no significance arithmetic. A third float school exists (Mathematica’s: track how many digits stay reliable and drop the rest, adaptively). The language does not ship it, on purpose. It makes precision dynamic and implicit, hiding the very decision the other two make explicit: BigFloat[M] makes you choose M, BigFloatArb makes you pay a visible bound; significance decides for you and changes its mind per operation. That is the kind of helpful magic the whole design rejects (the cost has to be visible, section 1). Whoever needs adaptive precision composes it from BigFloatArb (re-run wider until the bound is tight enough), explicitly, instead of getting it as a silent default.

    Literals resolve by context, like a character does (section 16). A numeric literal is comptime with no fixed type, exactly as 5 becomes u8/i64/int and 'w' becomes byte/codepoint/grapheme. It takes the binding’s declared type: x: BigInt = 123456789012345678901234567890, x: Decimal = 9.99. When context does not disambiguate, you must annotate: a loose x := <a literal past i64> is an error asking for a type, not a silent promotion to BigInt, the same discipline that makes 'é'.byte an explicit choice rather than a guess.

  • Optional[T] for absence; [N]T dimensioned array, []T slice, [*]T pointer-to-many (raw buffer that is owned; section 6).

  • noreturn, the type of divergence: that of a function that never returns control to the caller. It is distinct from the absence of a return value: fn f() returns (control comes back, it just brings no value; and fn() is the type of that function, without needing a unit), while fn panic(msg: string) -> noreturn does not return (there is no “the line after”). They diverge: infinite loop {}, panic, exit. It is not a special builtin construct, since user code also declares -> noreturn (an event-loop, an abort), and the type-checker’s rule is uniform: a noreturn value fits in any type context, because the path that would produce it never gets there. It is this that makes x := parse(s) catch |e| { panic(e) } type with x: int: the error arm calls panic (-> noreturn), diverges, and the error path never reaches the assignment, so x is the int of the Ok. (In type theory it is the bottom; the name speaks of what matters in practice, the return of control, not “never”.) A corner of fn(): since the return is absent (not a value, not noreturn), a generic that abstracts over the return type, g[R](f: fn() -> R), does not match fn() (there is no R to bind). It is extremely rare and workaroundable (wrap it in a fn() -> Empty); it is recorded because the generality “generic over the return” does not cover the function-without-return.

  • Size literals, in the mold of the duration ones (above): lexer notation converted to usize in bytes, without importing anything. The atom is the byte; orders of magnitude above it come in base-10 and base-2, distinguished by the prefix: 512b (bytes), 4kb/4kib (10³ vs 2¹⁰), 16mb/16mib, 2gb/2gib, 8tb/8tib. The b is byte; the i (base-2) only appears with a prefix (kib, never 512ib). There is no bit literal: a bit is type-width (uN), not amount-of-memory; memory is byte-addressed, so a size counts bytes, and a bare number in a size context (@limit(512)) counts bytes, the atom. For a runtime value, the helpers mem.kib(n)/mem.mib(n) (stdlib), symmetric with time.millis(n): literal for the static, helper for the dynamic.

  • Struct literal positional or named: Vec2{1.0; 2.0} or Vec2{x: 1.0; y: 2.0}.

Absence, default-value and null are three things, and the language only has the first. Optional[T] is typed absence (none/some), the only way to say “it may not be here”. There is no null (no null pointer, reference or binding, an invariant verified by grep in the stdlib). And there is no implicit zero-value Go-style: every binding is born with an explicit RHS (section 10, “there is no undefined”), so 0, "" and List.new() only arise when you write them, and then they are present and real values, not “missing”. An int that is 0 ≠ an Optional[int] that is none ≠ a field that did not arrive; a "" ≠ absence. A default is always explicit: the literal you type, or an argument (opt.or(d), map.get_or_insert(k, default)), not an automatic filling of the type. (Only mem.zero([]byte) zeroes raw bytes, at the memory level, and it is not a type zero-value.) In a wire format, the null token (JSON’s null, for example) is a value of the format’s model (a variant of an enum Value), and it maps to Optional.none only at the boundary; it is not a language null (see the encoding stdlib).

  • snake_case for values and functions; PascalCase for types. It is a convention, not a scope rule: visibility is pub, never capitalization (unlike Go).
  • Optional semicolon, auto-inserted at the end of a statement (Go style); as a consequence, K&R-style braces {} ({ on the same line).
  • Pipeline with . via UFCS (x.f() equals f(x)): xs.filter(p).map(g).collect(). There is no |>.
  • String concat with +; no dedicated operator.
  • Arithmetic operators (+ - * /) are the privilege of the core’s numeric types: the iN/uN/float family, the core-blessed Duration (builtin), and the arbitrary-precision family in math (BigInt, BigFloat[M], BigFloatArb[M], Decimal/Decimal[P, S]). A user-defined type gets no operator overload: it uses a named method (a.add(b)), consistent with section 13 (no overload) and with “operation = symbol only for what the core defines”. The ruler resolves the apparent tension (BigInt and Duration have +, collections do not): the arithmetic symbol is the core-numeric’s; the rest names. Being core-blessed is what lets the math types wear operators while staying out of the global scope (section 28): blessing is about the symbol, not about where the name lives.
  • No ? operator for error propagation (see section 7): propagation is explicit via catch.
  • Trailing comma is allowed everywhere a comma-separated list can appear — call arguments, parameters, tuple and array/slice elements, struct-literal fields, variant sets, type arguments. A dangling , before the closing bracket is accepted (and ignored), so multi-line lists format cleanly (one item per line, each ending in ,) without a special-case last line. It changes nothing semantically; forbidding it would only limit formatting styles.