16. Arrays and slices · 17. Structs, tuples, soa · 18. Maps and subscripts · 19. Enums, Option, Result, match
Every aggregate is a value (§9): binding, argument passing, and return copy it deeply, so two aggregate variables never share storage. This chapter defines the operations on the aggregate types — construction, indexing, mutation, growth, iteration, elimination — and their place semantics. It does not redefine the types: their identity, ranges, element/key restrictions, and recursion rules are fixed in §5.3 and are cross-referenced, not repeated, here. Where an operation reads or writes through part of an aggregate it produces a place (an lvalue); the general place, borrow, and inout rules are in §11.
16. Arrays and slices
16.1 Growable arrays [T]
[T] is the growable, value-semantic array of §5.3.1. An array literal [e1, …, en] constructs a [T] whose element type T is the common type of the elements (§6); every element MUST have the same type T. An empty literal carries no elements from which to infer T, so it MAY be written with an explicit element type — []int, []string, [][int]. A bare [] used as a := initializer is instead *pending*: it is grounded by the element type expected at its first use in the block (xs := [] then push(xs, 1) fixes xs to [int]), and is a compile error only if never grounded (§6.4). Binding an array deep-copies it:
xs := [10, 20, 30] # a [int]
_ys := []int # empty; element type required
_zs := xs # zs is an independent deep copy — mutating zs never touches xs
A complete program that sums an array by iterating it:
fn main():
xs := [2, 4, 6, 8]
total := 0
for x in xs:
total = total + x
println("sum = " + str(total))
sum = 20
T MAY be any storable type (int, float, string, a struct, an enum, a tuple, a map, another array, …), nested to any depth; the sole exclusions are void and bool in element-type position (§16.7). len(a) yields the current element count as an int.
16.2 Indexing and bounds
a[i] denotes the element at zero-based index i, an int. Indexing is bounds-checked: an implementation MUST abort with a diagnostic if i < 0 or i >= len(a), and MUST NOT read or write out-of-range storage. The check is normative and always active in a conforming implementation — it is not an optional hardening pass. Program-abort semantics are specified in §30.
Rationale: every value lives in one arena block, so an unchecked access would land on other live data rather than fault.
Read as an rvalue, a[i] yields the element by copy for a composite element and by value for a scalar one. Used on the left of an assignment or as the spine of a place, a[i] is a place (§16.3). Indexing a string (s[i]) or a bytes (b[i]) yields the i-th byte as an int and is not a place — see §5.2.5 and §5.2.6.
16.3 Element places
An array element is a mutable place: it MAY be written in situ without rebuilding the whole element. Writing through an element is a *projection* into the backing buffer; no pointer is exposed to Tycho, and the projection is bounds-checked exactly as a read is (§16.2). The following are all places:
struct Tagged:
x: int
tags: [string]
fn bump(n: inout int):
n = n + 1
a := [1, 2, 3]
i := 0
v := 9
ps := [Tagged(1, ["a"]), Tagged(2, ["b"])]
grid := [[10, 20], [30, 40]]
a[i] = v # replace an element
ps[0].x = 10 # a field of a struct element
push(ps[0].tags, "x") # grow an array-valued field of an element, in place
grid[1][1] = 60 # an element of a nested array
bump(&ps[1].x) # a field of an element passed as an `inout` argument
Value semantics is preserved: after qs := ps, a later ps[0].x = 10 leaves qs untouched, because each owns its buffer. The owning array of a projected element MUST be a mutable variable or field; projecting through a read-only borrowed parameter is rejected (§16.4, §11).
16.4 Growth: push, pop, reserve
Three built-in operations change an array's length or capacity (the builtins are catalogued in §29); each requires a mutable array place as its first argument.
| Form | Effect |
|---|---|
push(a, v) | Append a deep copy of v as the new last element; len grows by one. |
pop(a) | Remove the last element and return it (deep-copied into the caller). |
reserve(a, n) | Grow backing capacity to at least n; len is unchanged. |
push and pop require element type equality: v MUST have type T for a [T]. pop(a) on an empty array MUST abort; it is not silently zero-returning. reserve(a, n) is a capacity hint only — it copies the existing elements into a buffer of capacity ≥ n and is a no-op when n ≤ cap; it never changes len and never inserts elements .
An array parameter is a read-only borrow (§11): passed without a copy, but push, pop, reserve, or an index-write on it is a compile error. Copy it first (b := a) for a mutable local, or declare the parameter inout. A returned array is promoted into the caller's storage and never dangles (§10).
16.5 Fixed-size arrays [N]T
[N]T is the fixed-size array of §5.3.2: exactly N elements, stored inline and copied by value. Its operations differ from [T] on three points:
- Literal arity is exact. A literal bound to a
[N]TMUST have exactlyNelements;v: [3]int = [1, 2]is a compile error. A bare literal[1, 2, 3]is a dynamic[int]unless its destination type is a fixed-size array, in which case it coerces (element count and element types checked). - No growth or slicing.
push,pop,reserve, and slice expressions (§16.6) MUST be rejected on a[N]T; use a dynamic[T]when the length varies. lenis a compile-time constant.len(v)forv: [N]Tis the constantN, with no runtime length field. Indexing is bounds-checked against the staticN;==compares element-wise.
A [N]T MAY be a struct field (stored inline), a by-value parameter, and a return value. The size-generic form [$N]T — a template inferring N from a fixed-array argument, meaningful only as a function parameter — is specified with const generics in §7.4.
16.6 Slices xs[a:b]
A slice expression names a sub-range of a dynamic array. Four forms exist, each with default bounds:
| Form | Range |
|---|---|
xs[a:b] | indices a (inclusive) to b (exclusive) |
xs[a:] | a to len(xs) |
xs[:b] | 0 to b |
xs[:] | the whole array |
Every bound is checked: 0 ≤ a ≤ b ≤ len(xs) MUST hold, otherwise the program aborts. The result is an ordinary array value of the same type as xs, so its cost is decided entirely by its use:
- Passed to a function that only reads its parameter, a slice is a zero-copy view — the descriptor points into
xs's buffer, the same borrow an ordinary array argument is; nothing is copied. - Stored, returned, or pushed, a slice deep-copies into an owning array, so value semantics holds: mutating
xsafterward never touches the stored copy.
That is what keeps the view non-storable — it can never outlive or alias the buffer it came from — without a borrow checker. Slices compose (xs[1:5][1:3]). One aliasing constraint MUST be enforced: a slice of xs and an inout of xs MUST NOT be passed to the same call, since the inout could reallocate the viewed buffer.
A string slice s[a:b] (with the same s[a:] / s[:b] / s[:] forms) also exists, but unlike an array view it always copies into a fresh substring: there is no zero-copy string view. A string slice also clamps out-of-range bounds rather than aborting (start < 0 → 0, end > len → len, end < start → empty) — unlike an array slice, which aborts — because it is exactly substr(s, a, b), the equivalent function form (§29).
A bytes slice b[a:b] behaves identically to a string slice — always copies, clamps rather than aborts, same four bound forms — and yields bytes (§5.2.6). It is the same operation on the same buffer; only the static type of the result differs.
16.7 Element-type restriction
void MUST be rejected as a bracket-array element type in every form. bool is rejected only in the inline fixed-capacity forms — [N]T, [$N]T and bounded[N]T — which have no bool codegen. A dynamic [bool] is legal and fully supported: literal, push, index-write, iteration, str, ==, as a struct field, as a map value, and nested.
| element form | void | bool |
|---|---|---|
dynamic [T] | rejected | accepted |
fixed [N]T, [$N]T | rejected | rejected |
bounded[N]T | rejected | rejected |
The restriction applies only to the direct element of a bracket type; a bool may appear inside any array indirectly (e.g. a struct field of a [Struct] element).
16.8 Element-wise arithmetic
The five arithmetic operators of §13.2 apply to arrays. a OP b on two arrays yields a fresh array whose i-th element is a[i] OP b[i]; with one array and one scalar the scalar is broadcast over every element.
fn main():
a := [1, 2, 3]
b := [2, 2, 2]
println(str(a * b)) # element-wise
println(str(a * 2)) # broadcast, array on the left
println(str(2 - [5, 1])) # broadcast, scalar on the left — order kept
[2, 4, 6]
[2, 4, 6]
[-3, 1]
Legality is the scalar rule, element by element. a OP b is legal iff a[i] OP b[i] is legal, so the operator set follows the element type and the array form is never more permissive than the scalar form:
| element type | operators |
|---|---|
int, and u8/u16/u32/u64/i8/i16/i32/i64 | + - * / % |
float, f32, a newtype over int or float | + - * / |
char | + - |
Every other element type (string, bytes, a struct, a nested array, …) has no scalar arithmetic and gets none here. & | ^ << >> are not element-wise — they are not arithmetic — so an array operand reaches the bitwise and shift rules of §13.2 and is refused there in their existing wording, unchanged.
The char row is reached only by inference, since char has no type keyword (§5.2.4): an array literal of char_at calls or of character literals.
Both array kinds, and their two different mismatch rules. For a [N]T the length is static, so both operands MUST have the same N and a mismatch is a compile error — the same requirement == already makes to compare two fixed arrays element-wise (§16.5). For a [T] the lengths are not known until run time, so a mismatch aborts with a diagnostic naming both lengths (tycho: element-wise arithmetic on arrays of different lengths (3 and 2), exit status 1), joining the abort set of §30.2. Neither side is padded and neither is truncated: inventing an element would be a silent wrong answer.
Mixing the kinds is refused. A [N]T and a [T] cannot agree on a length at compile time and carry different mismatch rules, so a mixed expression has no one defensible behavior and is a compile error; copy one side into the other's kind first. bounded[N]T and the size-generic [$N]T (§7.4) are refused for the same reason — bounded carries a capacity *and* a separate live length, and no element-wise meaning for that pair is settled. The two arrays MUST also share an element type: [char] + [int] is a compile error.
Broadcast, both directions, order preserved. a OP s and s OP a are both defined for every legal operator, including the non-commutative -, / and %, and the operands are never reordered: 2 - [5, 1] is [-3, 1], [5, 1] - 2 is [3, -1]. The scalar is evaluated once, not once per element. Literal adaptation applies to the scalar against the element type, with the value-directional restriction of §8.1: a *literal* adapts ([1.0, 2.0] * 2 is [2.0, 4.0]), a *variable* never widens (with n := 2, [1.0, 2.0] * n is a compile error exactly as 1.0 * n is). After adaptation the scalar's type MUST equal the element type, which makes ['a', 'b'] + 1 a compile error even though the scalar 'a' + 1 is legal (§13.2's byte-domain rule).
Rationale: the broadcast form is never more permissive than the two-array form.
The result is a fresh array. Value semantics (§9) holds: neither operand is aliased or mutated, mutating a source after c := a * b never changes c, and growing one never grows the other. Every element goes through the same emit a scalar operation does, so an element never gets fewer guards than a scalar: division or modulo by an integer literal 0 is a compile error, by a zero *value* or a zero *element* it aborts (tycho: division by zero / tycho: modulo by zero, exit 1), and float/f32 division by zero stays IEEE (inf) — the §13.2 rules exactly, not a second set.
Its fixtures are in tests/ and tests/abort/ (Appendix E.2.1).
17. Structs, tuples, soa
17.1 Struct declaration and construction
A struct is the nominal product type of §5.3.4. It MUST be declared before it is used as a type. Construction is positional by default, in field-declaration order: Point(1, 2) sets the first declared field to 1 and the second to 2. A named form is also accepted: Point(y: 2, x: 1). Once one field is named, every field MUST be named exactly once; an unknown, duplicate or missing field is rejected. Named construction supplies the field type to each argument, so an empty literal can infer it from the field (edges: []). Each argument's type MUST match its field's type. Binding a struct deep-copies its whole tree — every heap-owning field (string, array, map, nested struct, at any depth) is duplicated — so two struct variables never share storage.
struct Point:
x: int
y: int
a := Point(1, 2) # positional, in declaration order
b := Point(y: 2, x: 1) # named, any order; every field exactly once
_c := a # deep copy; b.x, b.y independent of a
println(str(b.x) + " " + str(b.y))
As a complete program:
struct Point:
x: int
y: int
fn main():
p := Point(3, 4)
println("(" + str(p.x) + ", " + str(p.y) + ")")
(3, 4)
17.1a Packed layout
A struct declaration MAY carry the packed attribute. packed is a DECLARATION attribute, not a type constructor: it is a property of the aggregate, so it does not participate in type identity and never reaches the array-type table bounded[N]T uses.
A packed struct is laid out with no padding between fields and no trailing padding: its size is exactly the sum of its field sizes, and each field sits at the sum of the sizes before it. An unpacked struct's layout is implementation-defined (Appendix F).
Every field of a packed struct MUST be a fixed-width value type: int, float, bool, char, one of u8/u16/u32/u64/i8/i16/i32/i64/f32, a newtype over one of those, or another packed struct. A heap-owning field (string, bytes, an array, a map, an enum, an Option, a Result, a tuple) is refused at the declaration, and so is a $T type parameter. packed on any declaration other than a struct is refused.
Packing only ever REDUCES alignment, so a packed value is storable anywhere an unpacked one is; the 8-byte arena rounding of §22 is unaffected.
packed struct Ins: # exactly 9 bytes: 1 + 4 + 4
op: u8
a: i32
b: i32
fn main():
i := Ins(to_u8(3), to_i32(-1), to_i32(7))
println(str(i.op) + " " + str(i.a) + " " + str(i.b))
3 -1 7
17.1a-2 Stated alignment
A struct declaration MAY instead carry the align(N) attribute, written align(N) struct Name:. Like packed it is a DECLARATION attribute and not a type constructor, and like packed it does not participate in type identity.
align(N) RAISES the aggregate's alignment to N bytes: the declaration's size becomes a multiple of N, so consecutive elements of an array of it stay aligned. size_of$(T) reports that size (§17.1b). It never lowers alignment below what the fields already require.
N MUST be a literal power of two, and it MUST NOT exceed 8. The ceiling is the arena's: §22's allocator rounds every allocation to 8 bytes and guarantees nothing above that, so an alignment above 8 is refused at the declaration rather than accepted and not delivered. Raising the arena's own rounding was measured before this rule was written and costs 20-30% more arena bytes on every program.
packed and align(N) on the same declaration are a contradiction -- one removes padding and the other adds it -- and are refused. align on any declaration other than a struct is refused.
align(8) struct Pair: # every Pair, and every element of a [Pair], is 8-aligned
a: u8
b: u8
fn main():
p := Pair(to_u8(1), to_u8(2))
println(str(p.a) + " " + str(p.b))
1 2
17.1b The bytes bridge
A packed struct is the only aggregate whose storage a program may move into or out of a bytes value. Three forms:
size_of$(T)is the size of the structTin bytes, anint.TMUST be a packed struct or analign(N)struct (or a newtype over either). For a packed struct the value is exact, the sum of its field sizes. For analign(N)struct it is implementation-defined (Appendix F) but always a multiple ofN; this is the one placealign(N)can be observed from inside the language. Every other type's layout is implementation-defined and has no size a program can ask for.to_bytes(v), wherevis a packed struct, yields exactlysize_of$(T)bytes.from_bytes$(T)(b)reads abytesvalue back into aT.len(b)MUST equalsize_of$(T)exactly; any other length is a runtime error naming the type and both lengths. A shorter buffer is not accepted and padded, and a longer one is not accepted and truncated.
The byte order is LITTLE-ENDIAN on every host, not the host's own. Each field occupies its own width with the least significant byte first, fields in declaration order, a nested packed struct flattened in place. So the same program writes the same bytes on every target, which is what makes the pair usable for a file format rather than only for a same-machine round trip.
This is safe because §17.1a already bars every heap and pointer field: a packed struct is a flat run of fixed-width scalars, so no length, capacity or pointer read out of a foreign buffer can be believed by the runtime.
packed struct Ins:
op: u8
a: i32
b: i32
fn main():
b := to_bytes(Ins(to_u8(3), to_i32(-1), to_i32(7)))
println(str(size_of$(Ins)) + " " + str(len(b)) + " " + str(b[1]))
r := from_bytes$(Ins)(b)
println(str(r.op) + " " + str(r.a) + " " + str(r.b))
9 9 255
3 -1 7
17.2 Field access and places
A field is read with p.x and written with p.x = v. Writes compose through nesting and through array-valued fields, and a field MAY be taken as an inout argument; all of these are places:
struct Point:
x: int
y: int
struct Tagged:
x: int
tags: [string]
struct Span:
lo: Point
hi: Point
fn bump(n: inout int):
n = n + 1
r := Span(Point(0, 0), Point(1, 1))
p := Tagged(5, ["a"])
r.lo.x = 100 # a nested-struct field, in place
p.tags[0] = "x" # an element of an array-valued field
push(p.tags, "y") # grow an array-valued field, in place
bump(&p.x) # a field as an `inout` argument
Two structs compare with ==/!= field-wise, recursing into nested structs, arrays, and strings, so a == b holds exactly when b is an independent copy of a (§5.5). A struct MAY be a method (UFCS) receiver (§15).
17.3 Recursion only through a container
A struct field MAY be recursive only through a container whose elements are allocated out of line: children: [Node] inside Node is permitted. A direct by-value self-field — next: Node, or next: Option(Node) naming the enclosing struct inline — is rejected, because it would make the struct infinitely sized. This is a type-formation rule; it is stated normatively in §5.3.4 and enforced there.
17.4 Tuples
A tuple (T1, …, Tn) is the anonymous product of §5.3.3, with 2 to 8 elements (a 1-element or 9-element tuple is a compile error, (min) and (max)). Tuples are first-class values, not merely a return convention:
- Construction. A parenthesized list
(10, 20), or a barereturn a, b, builds a tuple. - Multiple return values. A function whose return type is a tuple returns several values as one tuple (§15).
- Positional access and places.
t.0,t.1, … read an element;t.0 = vwrites one in place (a tuple element is a writable place). An index out of0.. n-1is a compile error. - Equality. Two tuples compare element-wise with
==(§5.5).
Any element type is allowed, including heap-bearing ones ((string, [int])); a tuple is deep-copied on bind like every value.
17.5 Destructuring
A tuple-returning call (or any tuple value) MAY be destructured into several names. Two forms exist, distinguished by the binding operator:
| Form | Meaning |
|---|---|
a, b := f() | declares fresh locals a and b (new bindings) |
a, b = f() | assigns into existing variables a and b |
(a, b) = f() | assigns into 2–8 places, simultaneously (below) |
v.(x, y) = e | assigns into 2–8 components of one value, simultaneously (below) |
The right-hand side MUST be a tuple, and the number of targets MUST equal the tuple's arity; a mismatch is a compile error, as is a duplicate name in a := destructuring list. At most 8 targets are permitted. Each target receives its element deep-copied, preserving value semantics.
#### Simultaneous assignment
The parenthesised form (t1,..., tn) = e differs from the bare one in two ways. Its targets are places (§4.4) and not only names, so a field, an array element or a map value may stand on the left; and its evaluation order is specified: every right-hand side, and every index expression appearing in a target, is evaluated before any target is written. A swap therefore needs no temporary, and (a, b) = (b, a + b) advances a Fibonacci pair in one step — the a read on the right is the old one. In (i, xs[i]) = (2, 99) the element written is xs[old i], because an index belongs to the left side's address and not to the value.
A target MUST NOT be repeated. With every right-hand side evaluated first the two writes have no order to tell them apart, so the statement would have no meaning to give. Two targets are the same place when they are written the same way down to a name, a field, a tuple index and a literal index: a and a, p.x and p.x, xs[0] and xs[0]. Targets that are not decidable this way are not compared: whether xs[i] and xs[j] denote one slot is not decidable when the program is compiled.
A user subscript call is a place for the single-target form p = e, and it MUST NOT stand in this simultaneous-assignment list.
The form is a statement of its own and MUST NOT appear as the init or post clause of a three-clause for (§13): its bindings would evaluate once outside the loop instead of once per iteration, or outside the scope they belong to.
fn main():
a := 1
b := 2
(a, b) = (b, a + b) # a = 2, b = 3 -- NOT b = 4
println(str(a) + " " + str(b))
2 3
#### Lane names
On a value that stores its elements inline — [N]T, bounded[N]T and vector[N]T — the first four lanes have names. .x, .y, .z and .w are lanes 0, 1, 2 and 3; .r, .g, .b and .a are those same four lanes under a second spelling. v.x IS v[0] everywhere a field access is accepted: it is read, it is assigned to, and it is a swizzle component, so v.(x, y) and v.(0, 1) are the same program.
A name that means a lane the value does not have is rejected as a name rather than as an index: .z on a vector[2]int names lane 2 of two. A value with more than four lanes has no lane names at all, because four names cannot address a wider set; index it instead.
A struct is never reached by this rule. .x on a struct is the field x and nothing else, and a struct declaring fields named x, y or r is unaffected in every position.
The two spellings MAY be mixed within one swizzle: v.(x, g) is lanes 0 and 1. .r.g.b.a are aliases and not a second kind of component, and a swizzle is defined below as the tuple of its components written out — v.(x, g) IS (v.x, v.g), each half of which is legal on its own.
fn main():
v: vector[4]float = [1.0, 2.0, 3.0, 4.0]
v.(x, y) = v.(g, r)
println(str(v.x) + " " + str(v.y) + " " + str(v.b) + " " + str(v.w))
2.0 1.0 3.0 4.0
#### Swizzling
v.(c1,..., cn) names two to eight components of one value and is a tuple of them, in the order written. A component is a struct field name, or an integer lane index for an array, a bounded or a vector[N]T — each type's components are spelled the way that type is already read.
A swizzle is an ordinary expression: it MAY be read wherever a tuple value is accepted, and it MAY stand on the left of a simultaneous assignment, where it is the target list of those same components. v.(x, y) = v.(y, x) IS (v.x, v.y) = (v.y, v.x) and carries that form's evaluation order unchanged, so a swizzled swap needs no temporary and a three-component rotation v.(x, y, z) = v.(z, x, y) is not the same statement as three assignments written in order.
The base is repeated once per component, so it MUST be a variable or a field of one; a base that could be evaluated twice, such as a call result, is rejected, and so is one that carries an index (vs[i].(x, y)). An indexed base is rejected even though the spelled-out (vs[i].x, vs[i].y) = (vs[i].y, vs[i].x) is accepted: two targets are the same place only when their indices are literals, so under a non-literal index the repeated-component rule below could not be enforced. A component MUST NOT be repeated on the LEFT, by the rule above. On the right a repeat is ordinary duplication and is permitted: v.(x, x) is the pair (v.x, v.x).
struct V:
x: int
y: int
z: int
fn main():
v := V(1, 2, 3)
v.(x, y, z) = v.(z, x, y) # a rotation, not three assignments
println(str(v.x) + " " + str(v.y) + " " + str(v.z))
w: vector[4]float = [1.0, 2.0, 3.0, 4.0]
w.(0, 1) = w.(1, 0) # lanes, by index
println(str(w[0]) + " " + str(w[1]))
3 1 2
2.0 1.0
fn divmod(a: int, b: int) -> (int, int):
q := a / b
return q, a - q * b # builds (quotient, remainder)
quot, rem := divmod(17, 5) # fresh locals: quot = 3, rem = 2
17.6 soa [Struct]
soa [S] is the struct-of-arrays collection of §5.3.7: its element type S MUST be a struct, otherwise the type is rejected. It presents the same value-semantic array interface as [S] — push, pop, indexing, element places, len, deep-copy on bind — while storing each field of S in its own backing array. The externally observable behavior of soa [S] MUST match [S]; the difference is storage layout, not semantics.
18. Maps and subscripts
18.1 Map literals and type
A map [K: V] associates keys with values (§5.3.5). The legal key types K and the unrestricted value type V are defined in §5.3.5 and are not re-derived here. The map type follows from a literal or an annotation:
_counts := ["ada": 1, "alan": 2] # a [string: int]
_empty := []string: int # empty; key and value types required
A composite key works in literal form too ([Point(1, 2): 10]). len(m) yields the entry count.
18.2 m[k] as a place — writing inserts
m[k] on the left of an assignment, or as the spine of a place, is a place. Writing to m[k]:
- overwrites the value if
kis present; - inserts the entry if
kis absent, first initializing the slot toV's zero (for a compoundV, the zero-value is materialized before the write, so a field- or element-write lands on a valid zero-initialized value)
.
This makes the accumulator idioms one line each; the compiler proves the map is uniquely owned at the mutation and updates it in place, so a += loop is O(n) total, not O(n²):
struct Account:
balance: int
counts := []string: int
index := []string: [int]
totals := ["ada": Account(10)]
counts["ada"] += 1 # zero-initialized on first sight, then incremented
push(index["tycho"], 1) # grow a [string: [int]] value in place
totals["ada"].balance = 0 # mutate a struct-valued entry's field in place
18.3 m[k] as an rvalue — reading never inserts
Read as an rvalue, m[k] returns the value and MUST NOT insert. For an absent key it yields:
- the value type's zero when
Vis scalar; and - a deep copy of the zero value when
Vis composite.
Either way, an absent-key read leaves the map unchanged . When a non-zero default is wanted, use m.get (§18.5).
18.4 Membership, delete, len
| Form | Meaning |
|---|---|
k in m | membership test → bool; does not insert and does not iterate |
delete m[k] | remove the entry for k; a no-op if k is absent |
len(m) | entry count → int |
delete m[k] is a contextual-keyword statement that lowers to a functional map-delete rebinding the map ; it removes at most one entry and never aborts on a missing key.
18.5 m.get
m.get(k) is exactly m[k] read as an rvalue (§18.3): it returns V's zero (or a deep copy of it) for an absent key and never inserts. m.get(k, default) returns default on a miss instead of the zero value — the same read, spelled as a method:
counts := ["ada": 1]
counts["ada"] = counts.get("ada", 0) + 1 # equivalent to counts["ada"] += 1
18.6 keys(m)
keys(m) returns the map's live keys as an array [K] in insertion order — the order in which each key was first inserted. It is the way to iterate a map; k in m only tests membership. For a newtype or fieldless-enum key type, keys returns the wrapped key values.
counts := ["ada": 1, "alan": 2]
for k in keys(counts):
println(k + " = " + str(counts[k]))
A complete program building a map, updating a value in place, and iterating it in insertion order (§30.4):
fn main():
counts := ["ada": 1, "alan": 2]
counts["ada"] += 1
for k in keys(counts):
println(k + " = " + str(counts[k]))
ada = 2
alan = 2
18.7 Subscripts — user-defined projections
A subscript declares a compile-time place-macro: a reusable, zero-copy place into one of its parameters, generalizing the built-in &m[k]. Its grammar is given in §4.1.3:
subscript <name>(<recv>: T, <params>…) -> inout U:
yield &<place>
A subscript does not return a value and has no runtime object. At a call site recv.name(args) is not a function call: the compiler substitutes the arguments into the yielded place <place> and inlines it, after which the surrounding read or write flows through the ordinary place machinery. It is therefore usable both as a place and as an rvalue, and callable as a method on its first parameter, whose type selects the subscript:
struct Node:
weight: int
struct Graph:
nodes: [Node]
subscript edge(g: Graph, i: int) -> inout Node:
yield &g.nodes[i]
fn demo():
g := Graph([Node(1), Node(2)])
g.edge(1).weight = 10 # write in place through the projection — no copy
w := g.edge(0).weight # read through it
println(str(w))
The following rules are checked at compile time and MUST be enforced; each fails closed (the subscript is rejected, never silently mis-projected) :
- Yields a place. The body MUST be a single
yield &<place>, where<place>is a field/index spine; a non-place operand is rejected. - Type match. The yielded place's type MUST equal the declared
-> inout U; a mismatch is rejected at the call site (dispatch ). - Rooted in a parameter. The yielded place MUST be rooted in one of the subscript's parameters — it projects *into* an argument, so it cannot dangle. A place rooted in a fresh local is rejected.
- Each parameter used at most once in the yielded place, so no argument is double-evaluated when substituted.
- Concrete receiver. The receiver (first) parameter's type MUST NOT mention a type parameter;
subscript at(p: Pool($T), …)is rejected at the declaration
, whether or not it is ever called.
A subscript is a named place-macro on a concrete type, not a type-family operation: the receiver selects it by exact type. Declaring the subscript on a concrete instance (subscript at(p: Pool(int), i: int) -> inout int) is the supported form; a generic container that needs element access across all its instantiations exposes a generic function instead.
Rationale: a $T receiver could never match the monomorphised type a call site presents.
Value semantics is unchanged: a projection is a place into a value already owned or borrowed, and the usual mutability rules apply (writing through a projection into a by-value parameter mutates that parameter's private copy). A projection is scoped and transient — it MUST NOT be stored in a field or sent across a spawn/channel boundary — which is what keeps it compatible with the value/arena model. Subscripts make traversing an index-pool structure ergonomic; they are not a stored-reference facility.
19. Enums, Option, Result, match
19.1 Enum declaration and variants
An enum is the nominal sum type of §5.3.6: a value that is exactly one of several named variants, each carrying a payload of zero to eight types. Variant names are package-scoped: a variant is written bare (Circle, not Shape.Circle), and no two enums in one package may share a variant name (enums in different packages may reuse a bare name, disambiguated by the package qualifier).
A variant name also shares one namespace with the package's fn, const, struct, enum, type and handle names: a fn (or const) MUST NOT take the name of a variant in the same package, in either declaration order, and an implementation MUST reject the program (error: 'Name' is already defined). The rule exists because a bare variant wins at the use site, so such a function would be silently unreachable by its own name.
The four builtin constructors Ok, Err, Some and None are covered by the same rule in every package, not just the one that declares them: they are recognised by their spelling wherever they appear, so a declaration of one of those names — fn, const, struct, enum, type, handle, or a variant — is unreachable everywhere and MUST be rejected with the same diagnostic. The §3.7 builtin-name rule reaches the same verdict for the same reason — the builtin wins, so the declaration is unreachable by its own name. What differs is reach: §3.7 governs the *unqualified* call spelling, while these four are recognised by spelling in every position, so the rejection here is unconditional. A struct Ok used to declare cleanly and fail only at its first use, with a diagnostic about the builtin Result and no mention of the struct.
A run-time binding cannot collide with a variant at all, in any package: a local, parameter or pattern binding must start with a lowercase letter or _ (§3.5.1), so the collision this section rules out for fn and const is not expressible for a binding.
enum Shape:
Circle(float) # a variant with a payload
Rect(float, float)
Unit # a payload-less variant
An enum value is a small value-semantic descriptor whose payload lives out of line, so binding one deep-copies the whole payload and == compares two values structurally (§5.5). An enum MAY appear anywhere a type may. Recursive payloads (Add(Expr, Expr) inside Expr) are permitted and finite because the payload is allocated out of line. A generic enum Name($T) is monomorphized like a generic struct (§7).
19.2 Construction
A value is built by naming a variant and supplying its payload — Circle(2.0), Rect(3.0, 4.0) — or, for a payload-less variant, by naming it bare: Unit. Each payload argument's type MUST match the variant's declared payload type.
19.3 match — the exhaustive eliminator
match dispatches on the variant and binds the payload in one step. To ask only which variant a value holds, without binding anything, use is (§19.8). Two rules make match safe:
- Exhaustive. Every variant of the matched type MUST have an arm; a missing variant is a compile error. A wildcard arm
_:matches every remaining variant and MAY stand in for the unlisted cases when a catch-all is wanted. - Arm-local bindings. A payload binding exists only inside its own arm; there is no way to read a payload that does not belong to the current variant.
enum Shape:
Circle(float)
Rect(int, int)
Unit
s := Circle(1.5)
match s:
Circle(r): # r bound to the payload
println(str(r))
Rect(w, h): # multiple payload fields bind in order
println(str(w * h))
Unit: # no payload, no bindings
println("unit")
19.4 Statement match versus value match
match exists in two positions:
- Statement
matchis a compound statement (§4.3.2); its arms are blocks that may contain any statements and produce a value by assigning orreturn-ing from inside each arm. - Value
matchstands as the whole right-hand side of a:=, a typedx: T =, a plain assignment, or areturn(tail position). Each arm is a block whose final statement is a value expression (a bare value expression is the one-statement case); leading ordinary statements may precede it. That final expression's value becomes the result; all arms MUST have the same type, which is the type of the whole expression. A valuematchis exhaustive exactly as the statement form is. The value form ofifsimilarly requires anelse(every path must produce a value). This desugars to the declare-then-assign-in-each-arm form (§14).
enum Status:
Active
Idle
Done
status := Active
_label := match status: # value match — each arm ends in a value expression
Active: "on"
Idle: "waiting"
Done: "finished"
The same value match as a complete program, returned from a function:
enum Status:
Active
Idle
Done
fn label(s: Status) -> string:
return match s:
Active: "on"
Idle: "waiting"
Done: "finished"
fn main():
println(label(Idle))
waiting
19.5 Option(T)
Option(T) is the built-in enum with variants Some(T) and None (§5.3.6). It replaces the null reference: an absent value is *typed* absent and cannot be read without an exhaustive match. T MAY be any type, including a nested Option(Option(int)).
None carries no type of its own, so a bare None is permitted only where the expected type is already known — a return type, a declaration annotation (box: Option(string) = None), an assignment target, or a call argument. A bare x := None with no annotation is instead *pending* — grounded by its first use (e.g. x = Some(5)), and a compile error only if never grounded. (Unlike None, a bare x := Ok(1) or x := Err(e) *is* rejected immediately; the Result constructors are not part of the pending set.) This context-grounding of type-incomplete literals is specified in §6.4.
19.6 Result(T, E)
Result(T, E) is the built-in enum with variants Ok(T) and Err(E) (§5.3.6) — error handling without exceptions. A fallible function returns one, and the caller handles both outcomes in a match. Both T and E MAY be any type. Like None, a bare Ok(v) fixes only T and a bare Err(e) fixes only E; the other parameter is grounded from context (§6.4), and a bare x := Ok(1) is a compile error.
19.7 or_return
or_return is a postfix operator that propagates a failure without a match. It binds tighter than any arithmetic, so it is valid anywhere the unwrapped value is wanted (foo(parse(s) or_return), return Ok(parse(s) or_return + 1)). To return something other than the unchanged failure — a wrapped error, a fresh one, or a plain false — use or_else (§14.6.1).
On a Result, v := expr or_return:
- binds
vto the payload whenexprisOk(v); and - otherwise returns that
Errfrom the enclosing function, which MUST itself returnResult(_, E)with the same error typeE— a differingEis a compile error.
On an Option, v := opt or_return binds v on Some(v) and returns None; the enclosing function MUST return an Option.
The short-circuited payload (Err's error, or None) is promoted into the caller's storage, so it outlives the return (§10). or_return MUST NOT appear inside a parallel for body — a chunk has no early exit .
fn parse_digit(s: string) -> Result(int, string):
if len(s) == 1 and s >= "0" and s <= "9":
return Ok(to_int(to_float(s[0]) - to_float("0"[0])))
return Err("not a digit: " + s)
fn add_two(a: string, b: string) -> Result(int, string):
x := parse_digit(a) or_return # Ok → bind x; Err → return it from add_two
y := parse_digit(b) or_return
return Ok(x + y)
A complete program using or_return to thread Result and match to consume it:
fn checked(n: int) -> Result(int, string):
if n < 0:
return Err("negative")
return Ok(n)
fn add(a: int, b: int) -> Result(int, string):
x := checked(a) or_return
y := checked(b) or_return
return Ok(x + y)
fn main():
match add(3, 4):
Ok(v): println("ok " + str(v))
Err(e): println("err " + e)
ok 7
19.8 is — the variant test
match is the eliminator, but it is not a *question*: it dispatches and binds in one step, so asking only "which variant is this?" costs an arm per variant. is is that question as an expression.
v is V yields bool — true iff v currently holds variant V (§13.2). Its rules:
- The left operand MUST be an enum, an
Optionor aResult. Any other type is a compile error. - The right operand is a variant name, not an expression, spelled exactly as a
matcharm spells it: bareVwithin the declaring package, qualifiedpkg.Vfrom outside it (§19.1). A name that is not a variant of that enum is a compile error naming both, never a test that is silentlyfalse. - It binds nothing.
isanswers which variant;matchis still the only way to read a payload, and remains the only exhaustive form. - It does not chain (
a is X is Yis a compile error).
Unlike ==, it applies to a payload-carrying variant: V alone is not a value there, so v == V is refused, and before is the only test was a match. For a payload-less variant v == V stays legal and means the same thing (§13.2).
enum Shape:
Circle(float)
Rect(float, float)
Unit
fn main():
s := Rect(3.0, 4.0)
println(str(s is Rect))
println(str(s is Circle))
println(str(Unit is Unit))
if s is Rect and not (s is Unit):
println("a rect, and not the unit")
true
false
true
a rect, and not the unit
Option and Result answer is on the same terms, with their constructors' own names: Some / None for an Option (§19.5), Ok / Err for a Result (§19.6). Those four names are never package-qualified. A name from the other family — r is Some on a Result — is a compile error naming the type and the two names that are valid, on the same rule as an unknown enum variant above. or_return still propagates; is only asks.
fn lookup(k: int) -> Option(int):
if k > 0:
return Some(k)
return None
fn parse(s: string) -> Result(int, string):
if s == "1":
return Ok(1)
return Err("bad")
fn main():
println(str(lookup(3) is Some))
println(str(lookup(0) is Some))
println(str(parse("1") is Ok))
println(str(parse("x") is Err))
true
false
true
true