Goose

The Goose Language — Specification (v4 draft)

This working specification defines Goose's syntax, semantics, and compilation model. It provides the detail needed to implement the language, without the formal structure of an ISO specification. Appendix B lists unresolved items. A readable account of the same rules, with an example for each, is the language reference.

Priorities, in order: Speed, Safety, Expressiveness. Safety here means: no out-of-bounds access, no access to memory at a type other than the one it was written with, no dangling references into freed or retyped memory. It does not mean Rust-style aliasing control; type-safe reuse of an element (reading a valid but logically stale value) is permitted where explicitly noted.


1. Core concepts

1.1 Size classes

Every type belongs to exactly one size class:

An ordinary compound type's class is the max over its parts (fixed < variable < resizable), subject to the placement rules in §3.4. Root structs retain their class regardless of their fields; references to them are fixed-size.

Additionally, a type is flat if it contains no references, slices, or relative references at any depth and is not a root struct. Only flat values may cross thread boundaries (§11.2).

1.2 Memory model

A Goose program's memory consists of:

There is no general-purpose heap and no malloc. All dynamic allocation is expressed as values living on data stacks, owned by locals (or globals). (The runtime itself may allocate internally for thread queues, §11.2.)

Rewinding a data stack makes its storage reusable, while normally keeping its pages committed. After a large workload, trim_memory() from os can ask the operating system to discard unused complete pages of the calling thread program's stacks. This preserves all live values and the virtual reservations; subsequent growth remains valid.

1.3 The stack invariant

For each data stack:

  1. Values are pushed in lifetime order and popped en masse when the owning scope exits. Lifetimes on one stack are strictly nested.
  2. At most one resizable value is live per stack, and for its entire lifetime it is the topmost value on that stack. The region from a live resizable's first element byte to the stack's current top belongs exclusively to its elements; its own length header lives below its elements or outside the stack entirely (Appendix C). Growth is a pointer bump, and no capacity checks are emitted: address space is pre-reserved, and guard pages catch pathological overflow and abort safely.
  3. Two simultaneously-live resizable values never share a stack. Locals with disjoint (sequential) lifetimes reuse stacks.
  4. A variable value may be buried under later allocations on the same stack once its construction completes. While a value is under construction on stack S, nothing else may allocate on S, nor may the resizable value it is being added to shrink. Nested variable parts are constructed in order, as part of the same construction.
  5. Fixed-size values are placed on the native stack / in registers whenever possible; the length header of an outermost resizable local likewise lives in the owning frame, not on the data stack (Appendix C).

The compiler proves all of this statically; there is no runtime bookkeeping beyond the bump pointers themselves.


2. Lexical structure and syntax style

The syntax follows C and Rust: {} blocks, // and /* */ comments (block comments nest), semicolon-terminated statements, postfix type annotations (x: T). Identifiers [A-Za-z_][A-Za-z0-9_]*. A UTF-8 byte-order mark at the start of a source file is ignored.

Literals:

The language is expression-oriented: if, match, block and a bare { … } block are expressions; a block's value is its trailing expression. Assignment and ++/-- are statements, not expressions. A trailing construct that cannot supply a value is a statement rather than the block's value. Such constructs are an if without a final else (including an else if chain) or with a branch that cannot supply a value, a match with an arm that cannot, a loop none of whose breaks carries a value (§6.4), and a block or scope ending in one of those. A function whose result type is inferred, and a function value's body, may therefore end in one of them and produce nothing.

Statement termination. Expression statements end with ;. A statement that is a block-ended construct needs no ;: if, match, block, the loops, guard … else { }, a nested fn, or a call with a trailing function block (§7.6). Such a statement ends at its closing }. Operators never continue it (foo(1) { it } followed by -x; is two statements), and using one as an operand requires parens (let v = (if c { 1 } else { 2 }) + 3;). A redundant ; after a block is harmless. Trailing commas are allowed in every comma-separated bracketed list: struct/array literals, call arguments, parameter and generic lists, field lists, match arms.

Type syntax is postfix throughout: T[k] array of T, T& reference to T, T[:] slice of T, T? optional T, T&<u8> relative reference, T&<u8 in pool> relative reference measured from a named pool, Shape.. variable-mode ADT. The only prefix, const, qualifies the first reference or slice built on the base type: const T& and const T[:] are a reference and a slice through which the pointee, or the elements, cannot be written (§9.5). To qualify an outer reference or slice, parenthesize the inner type, as in const (u8[:])&.

ns::name names a declaration of namespace ns (§11.1) wherever a declaration can be named: a type, a call, a global, a return … from target, or a pool. ::name explicitly names a global declaration. The :: token is distinct from ., so image::Shape.Circle is the variant of a namespaced enum.

Evaluation order is left-to-right everywhere: operands, arguments, and field initializers. Named struct literals keep field initializers in construction order by requiring declaration order (§4.2). Overlapping copies have memmove semantics.

An earlier by-value operand is read before a later operand executes; a reference or slice retains its address or view, not a snapshot of its contents. Indexing and slicing sample the receiver's region and length before their index or bounds run. Assignment resolves the destination first; compound assignment also reads its old value before the right-hand side. Whole-resizable assignment clears before constructing (§4.4).

A grammar sketch and precedence table are in Appendix D.


3. Types

3.1 Scalars

Integer literals are constants without a committed type. They adapt to any integer type that can hold their value: let x: u8 = 255; is valid, but = 256 is a compile error. Without a type constraint, they are i64, or u64 for values above i64.max. A named constant is a let or const global with no written type whose initializer is an integer constant expression (let SZ = 15;, let B = SZ * 2;, let NEG = -3;). It is also a constant without a committed type: each use of it adapts as its value would if written there, both at a destination (D { len: SZ } with len: u8) and as an operand (b + SZ with b: u8 is an 8-bit add). Where nothing constrains it, it is an i64. A global with a written type (let SZ: i64 = 15;), a local let and a var are values of their type. (An untyped float let, local or global, adapts too, §6.3.) A use that adapts a named constant relies on the constant keeping its initializer's value, and a writable reference to it could change that value. So, as for a compile-time size (§11.1), such a reference is an error, whether it comes before or after the use. A division by a named constant that is zero is left to run time, rather than rejected as a constant division by zero (§6.2). All other conversions are implicit when provably value-preserving, and need an explicit cast otherwise, with one exception: an integer becomes a float implicitly, rounding as float arithmetic does (§6.3).

3.2 Structs

struct X { a: i8[3], b: i32, c: i64 = 0 }

Struct and enum declarations introduce nominal types. type Name = T; declares an alias. Generic structs: struct Pair<T> { a: T, b: T }, monomorphized like functions.

A generic type's instantiations must be finite, as a function's specializations must be (§7.7). Each instantiation checks its fields' types (including a variant's payload) and instantiates the types they name. Fields that name the declaration again, directly or through other declarations, with a larger type argument each round would therefore instantiate without end: struct P<T> { v: T, next: P<T[]>? } needs a P<T[]>, which needs a P<T[][]>, and so on, whether or not a program ever follows next. As for polymorphic recursion (§7.8), the compiler rejects an instantiation that would make a chain of more than 16 instantiations of one declaration, each needed by the one before, and the error names the first few. Fields that name the declaration with the same arguments (struct L<T> { v: T, next: L<T>? }), or with arguments that stop changing, reach an instantiation that was already made instead. So only a type that nests one declaration more than 16 deep in its own arguments reaches the limit.

3.3 The array family

All array types share element type T and differ in how their size behaves and is stored. In all of them, the representation is [metadata][element data...], inline, packed (exact layouts in Appendix C).

Type Class Stored metadata Notes
T[k] fixed none k a compile-time constant expression (literals, named constants, a global's fixed .len/.cap, arithmetic)
T[] variable length (u32 default) size chosen at construction
T[u8] etc. variable length of given unsigned int type explicit length field type: u8–u64 only
T[varint] variable varint length most compact
T[..k] fixed length only (smallest int type fitting k) capacity k is static (constant expression); in-place grow/shrink up to k
T[..] variable capacity + length (u32 default) capacity chosen at construction; in-place grow/shrink up to it
T[>..] resizable (grow-only) length element region tops a data stack; grow = bump
T[>..<] resizable (grow-shrink) length as above, may also shrink

A size or capacity k is fixed at compile time, as is the count n of a fill literal [v; n] (§4.2). A name in a size or count is a named constant (a let or const global, §11.1), and resolves as any name does, lexically first. Such an expression may also use X.len of a global X whose declaration fixes its length, and X.cap of a global whose declaration fixes its capacity (const names = ["a", "bc"]; var counts: i64[names.len];). A written type T[k] fixes the length, and T[..k] the capacity. With no written type, an array, fill or string literal initializer fixes the length, and an initializer naming another such global fixes what that global's declaration fixes. Where X's type also fixes the length or capacity (a written type, or the T[k] an array or fill literal gives), nothing can change it, and X may be a var. Where the type does not, as a string literal's const u8[:] does not (§3.7), the length is taken as a named constant's value is: X may not be a var, and may not be bound to a writable reference (§11.1), which a let holding a literal's slice never is (§9.5). X.len and X.cap are i64 values, as any .len is, and do not adapt as a literal does (§3.1).

A local with the same name as such a global hides the global there and is not a constant: a parameter, a local variable, or a local around a nested function or block. So do a type parameter and a nested function of that name. A size naming any of these is an error, as a match pattern naming one is (§8.1); a qualified name (::K, ns::K) reaches the global past it. A size is also an error where it names one of a function's or a block's parameters or type parameters in that function's or block's parameter and result types, or one of a generic struct's or enum's type parameters in its field types. A type parameter stands for a type, and there are no size parameters (fn f<N>(a: i64[N]) is an error). The names in a type alias's sizes resolve where the alias is declared.

Indexing is bounds-checked: against k for fixed arrays (with checks statically elided where provable), and against the current length for all others.

A stored length must hold the actual element count without truncation. For example, T[u8] can hold at most 255 elements. A construction exceeding its length field's range must be rejected when known statically or abort when discovered at runtime, including when adapting another array, a call result or a string. It must never produce an image whose metadata and element region disagree.

Element restrictions:

Growth operations (push, append, …) exist only on resizable arrays and on limited arrays ([..k], [..]) up to capacity (exceeding capacity aborts). Shrink operations (pop, pop_n, resize downward, clear) exist on limited arrays anywhere, on [>..<] wherever no live variable refers into it (§5.2), and on a grow-only [>..] exactly where the compiler can see that no reference or slice into it is live (§5.1). They abort when they would shrink below empty (pop on an empty array, pop_n of more elements than the array holds, resize to a negative length).

Built-in members, which exist as the rules above allow:

Growth always supplies element values, so no operation can expose uninitialized slots (§5.3). Per UFCS these are ordinary functions: a.push(v) is push(a, v).

a.range_index(s) -> i64, on the same array kinds and fixed-size element types as index_of, returns the start index of a slice of those elements. Unless exact provenance proves containment, a nonempty slice is checked for element alignment and complete containment within the receiver's current length. An empty slice from any origin returns zero. A failed check aborts in every build. a.check_length(n) -> i64, on resizable arrays of fixed-size elements, rejects a negative count or one whose element bytes exceed a data-stack reservation and otherwise returns the count. It evaluates the count once and changes no storage; it does not reserve space or check the space left after the array's current contents. Either takes a string literal as a last argument, a.range_index(s, "msg") or a.check_length(n, "msg"): a failed check then aborts with that message, as abort("msg") would, which lets a library say what went wrong in its own terms. These operations do not weaken index_of's static provenance requirement.

dst.move_from(src) copies the values of a slice into a writable slice of the same fixed-size element type. Lengths must agree; a mismatch aborts before any element is written in every build. The copy behaves as though the source values were saved first, including when the slices overlap; it does not clear the source. Both slices are evaluated once, receiver first. Copying must satisfy the usual stored-reference lifetime rules (§9.2), and elements containing self-relative references cannot be copied (§3.9). Empty slices, including defaults, are valid.

3.4 Placement rules (what may contain what)

An ordinary struct owns at most one resizable, on its tail. A root struct groups multiple independent components:

root struct State<T> {
    items: T[>..] = [],
    scratch: i64[>..<] = [],
    count: i64 = 0
}

root is contextual immediately before struct; it remains an identifier elsewhere. Fields, generic arguments, defaults, namespaces and field qualifiers work as for ordinary structs. Fields may be fixed, variable or resizable in any order, including ordinary structs with a resizable tail. Each field is a separate storage root sharing the owner's lifetime. Its own internal placement rules still apply. pad is forbidden on the root declaration, which has no packed layout; an ordinary component struct may contain padding.

An owner is constructed directly by a literal at its declaration, for example var s = State<i64> { .. };. A typed global without an initializer uses the usual field defaults. Initializers run once in field order; construction checks prevent observing incomplete storage. All component stacks are restored when the scope exits, including nonlocal exits during partial construction. The number of stacks is statically determined. An owner with variable or resizable components cannot remain live across a recursive-cycle call (§7.8); a root whose fields are all fixed needs no dynamic component stacks.

Owners cannot be copied, assigned whole, passed or returned by value, or used as temporary values. An unannotated binding from an existing owner borrows it, as with other nonfixed lvalues. Functions take State<T>&; references may be stored, optional, returned or captured when their ordinary lifetimes permit. Field assignment, borrowing and array operations retain their usual rules. Different fields remain distinct regions even when their element types match: shrinking one does not invalidate views into another. Ordinary references stored in a field can still refer elsewhere, and shrink checks follow those references. Self-relative links stay within one component's permitted region (§3.9).

Root owners support formatting and field inspection by reference, but have no whole-value equality, arithmetic, raw serialization or public C layout. Eligible components can be serialized or viewed separately. Root owners and their references cannot cross worker/queue boundaries; workers may create local owners but cannot directly or transitively use root globals (§11.2). Unused root globals do not restrict a worker. See Appendix C.2 for the private representation.

No type contains itself by value, either directly or through what it contains (fields, payloads in either ADT mode, variant types, array elements), since it would then be part of itself. A type may refer to itself, since a reference or a slice is fixed-size whatever it points at: an ADT's payload may link to its own ADT or one of its variants, and a type behind a reference may contain the type holding the reference (enum List { Nil, Cons { v: i64, next: List? } }, or struct A { b: B? } with struct B { a: A }).

3.5 Algebraic data types

enum Shape {
    Circle { r: f64 },
    Rect { w: f64, h: f64 },
    Point,
}

ADTs are the only dynamic-polymorphism mechanism in the language: there is no inheritance, and there are no traits or vtables. An ADT is represented as an integer tag (the smallest storage type that fits the variant count, default u8) followed by the payload.

Every ADT type can be used in two modes, chosen at the use site by the containing declaration:

Allowing either variant replacement or interior references, but not both, preserves the soundness of existential types (Grossman, "Existential Types for Imperative Languages").

Payload-less ADTs are ordinary C-like enums and are fixed, 1 byte by default. Variant types (Shape.Circle) are themselves nominal struct-like types (§8.2).

3.6 varint fields

varint is LEB128: little-endian base-128, 7 payload bits per byte, high bit = continuation; 1–10 bytes; full 64-bit range. Stored encodings are canonical: the shortest encoding of the unsigned or zigzag-transformed value. A verified byte image must preserve that invariant (§12). LEB was chosen because this type is optimized for values that are usually very small but have occasional outliers, where it outperformed other formats (measurements summarized in Appendix B, Resolved, item 1).

There is one varint type, with two encodings that depend on its position. The difference is user-visible whenever Goose data is serialized directly:

Because the two encodings differ, varint values are never copied byte-wise between contexts: any varint-to-varint construction (e.g. a struct varint field initialized from arr.len) goes through i64: decode, then re-encode.

Restrictions: a varint field/element is written only at construction of its containing value; changing it means reconstructing the container. Reads decode to i64; construction stores accept any integer type except u64 (a varint holds exactly the i64 value range). A struct containing varint fields is variable-class. References to varint fields are always read-only (§3.8). Such a field is still storage of type varint. A varint& destination binds it without &, as a reference destination binds any lvalue of its pointee type (§4.1). An i64& does not bind it, although it reads as an i64. That i64 is not storage, so a format overload taking an i64 by reference is given it in a read-only temporary (§3.7).

3.7 Strings

There is no built-in string type. A "string" is any array-family type with element u8: u8[] (flat string with a fixed length), u8[varint] (compact), u8[>..] (string builder), etc. String literals are static constant u8 data. They are implicitly copyable into any of these representations and usable directly as slices of type const u8[:] (§9.5). That type makes them read-only, so a literal is stored only in a slot declared const. No encoding is enforced; UTF-8 is a library-level convention.

Text. Every scalar, bool, and u8 array or slice has a text form:

Three builtins produce it. Each takes any number of arguments and inserts nothing between them:

A character literal is an integer (§2), so str('x') is "120"; a byte is appended with push.

Every other value type renders structurally, so print(v) shows any value: an array or slice of other elements as [1, 2, 3], a struct or ADT variant as its positional literal with the type name (vec3<f32> { 1.0, 2.0, 3.0 }, Circle { 1.5 }, a payload-less variant as Shape.Dot), a reference as its pointee and a null optional as null. A u8 array nested inside an aggregate is quoted and escaped. References are followed as deep as they go, including into values of the same type as the one holding them. A value whose references lead back into itself therefore never finishes rendering: its thread runs out of native stack, which is a safe abort (§9.3).

A user overload fn format(out: u8[>..]&, v: T) (taking T by value or by reference) is used instead to render a T, wherever a T occurs in an argument, so a type can choose its own text once for all three builtins. A generic overload whose value parameter is a struct, ADT or variant pattern, such as fn format<T>(out: u8[>..]&, b: Box<T>), renders each instance of it the same way, its type variables bound as at a call (§7.1); a non-generic overload for one instance is preferred. A generic overload taking a bare type variable, or an array or slice of one, is an ordinary overload and does not change how anything renders. A T that is not fixed-size is never copied implicitly (§4.1), so its overload takes it by reference. An overload that takes the T by reference is given the storage the T lies in, as a reference parameter is (§4.1): the variable, field or element holding it, or what a reference argument names, and it is as writable as that storage is (§9.5). A T that is not storage lies in a read-only temporary. That is the case for a call's, an operator's or a cast's result, a literal, a .len or .cap, and the value of an if, match or block, which is a copy of what the branch taken gives, even where that names a variable (§4.1). The overload is looked up in the namespace T is declared in, then globally (§11.1): rendering follows the type, not the namespace of the code printing it. A type that reaches itself through references renders structurally only where no overload renders any part of it, at any depth. Otherwise the type needs an overload of its own, which may render its parts through their overloads.

The three builtins evaluate and render their arguments in order, evaluating each one just before rendering its text. They call the overload in the middle of rendering the argument that holds the T. So neither the variables named by the arguments after that one, nor the rest of that argument, may refer into what the overload shrinks (§5.1).

An array's elements, and a variable-mode ADT's payload (§3.5), are rendered where they lie, using the count and the variant read when their rendering begins. So an overload called for one of their parts may not shrink the storage they lie in. Such a shrink either frees the elements or, as a whole assignment does (§4.4), rebuilds every variable-size part of the value holding them. A reference to a whole resizable value is no exception.

A slice's elements are rendered from the view the slice had when their rendering began, and a value behind a reference from where the reference pointed at that time. An overload that re-points the slice or rebinds the reference in the meantime does not change how the rest is rendered (§2). A fixed-mode ADT's payload is rendered from a copy taken once its tag is read, since an overload may overwrite the ADT with another variant and nothing may refer into its payload (§3.5). The rest of the payload is therefore still the variant the rendering began with, and an overload that takes a part of it by reference is given that part of the copy, read-only. The payload renders as its variant's literal even where there is an overload for the variant type; that overload renders values of the variant type.

3.8 References

T& is a reference to a T. It is one machine address, with no pointer arithmetic and no casts, and it never dangles.

References are transparent (like C++ references): an expression that denotes a reference behaves as its pointee in every value context. Reads, arithmetic, comparisons (r1 == r2 compares the pointees), passing to a by-value parameter, and print(r) all operate on the target. There is no dereference operator; the load is implicit (copy(r) names a copy of the pointee where §4.1 asks for one).

Optionals. References are non-nullable by default. T? is an optional T: it is represented as a nullable reference to T (null = address 0, so it takes no extra space), with all reference semantics and restrictions (roots, lifetimes, writability). This composes with any type: pass i64? for an optional integer. Applied to a type that is already a reference, ? makes that reference nullable (T&? ≡ T?). The same holds for a type argument that is a reference: T? of Node& is Node?, and of Node&<u32 in pool> it is the optional relative reference. T& of a reference type argument is that reference itself, just as & of a location holding a reference gives the stored reference (loaded, if relative). The literal null is the empty value of any optional type; an optional struct field with no declared default defaults to null. Optionals are not transparent: access requires narrowing via if/while/guard/assert(r)/== null/!= null tests. Flow typing: inside the guarded region the optional is a T& in every respect. It is transparent, can be written through, and is bound by .= (if n { let m .= n; … }). A while condition narrows its body in the same way even when the body rebinds the variable, since the test runs again before each iteration (while n { …; n .= n.next; }). Elsewhere, rebinding the variable ends the narrowing. o .= &x / o .= null rebind an optional. A rebind to a plain reference narrows it, and a rebind to anything that may be null un-narrows it.

What references may point to. Anything except the interior of a fixed-mode ADT payload (§3.5). A reference into a grow-shrink array [>..<] is held only in a variable, and must no longer be live when the array next shrinks (§5.2).

References into a grow-only resizable [>..] remain valid for as long as they can be named: grown memory never moves, and the array shrinks only where the compiler can see that no such reference or slice is live (§5.1). References into limited arrays [..k]/[..] are also allowed and stay type-valid across pop/push reuse (§5.3).

3.9 Relative references

A relative reference is a storage form of reference, stored as a narrow offset instead of an address. It is written T&<u8>, T&<u16>, T&<u32>, T&<u64> or T&<varint>; widths are written as unsigned types (or varint), like array length field types. It comes in two forms, differing only in what the offset is measured from: self-relative, the default, measured from the offset field itself; and pool-relative, written T&<u32 in pool>, measured from a named pool's base.

Both forms can be made optional, written T&<u8>?, which reserves offset 0 for null. A pool-relative offset is biased by one. A self-relative field can share its address with an enclosing value, so a non-null store must not encode as the optional's null. Such a store aborts, and a collision that is known statically may be rejected during compilation. A non-optional self-relative reference has no null sentinel: offset 0 denotes the address of its own field, as with self in a value's first field. Null is therefore representable in any relative location, whatever the location's root, and a value known to be null stores into an optional relative slot without a root check of its own. The values known to be null are:

As one alternative of a merged value (§9.2), a null names no array: if c { null } else { &pool[i] } is a link into pool. So a sentinel-ended chain does not force plain links on the whole structure.

Self-relative. Constrained to point within the same enclosing array/pool as the location storing it.

Pool-relative (in pool). pool names a global var (or reusable var) of a grow-only resizable type ([>..]) whose storage can hold a T by value: as an element, or as a by-value field of an element, transitively (the candidate notion of §9.5). Anything else is a compile error. For a local or parameter pool, the error points to the self-relative form, since the pool is named where the field is declared, and a local's name means nothing there.

The name may also select a mutable direct array field of a global var root struct, as in Node&<u32 in nodes.items>. The owner name may be namespace qualified. The compiler resolves the declaration and field statically; aliases, reference crossings, array indexing and nested field paths are not supported. Each component is a separate pool identity with its own base. The same grow-only, pointee-containment and width requirements apply to the array field.

Values. A relative reference is storage (a field, an element, or a variable or parameter whose declaration writes the type or an alias of it), never a value. Loading one yields the plain reference, and the plain reference is what anything else receives. A function's result may not be declared relative; declare the plain T& or T? instead, which a relative slot receiving it encodes. A result that a type argument makes relative, such as the -> T of fn get<T>(a: T[>..]&, i: i64) over an array of links, is the plain reference, as indexing gives it. So is a variable or parameter that a type argument makes relative: let t: T and v: T over an array of links hold the plain reference. This is how generic code that moves elements through a T local (std's swap_at, §3.8) moves links as it moves plain references. Each store back into an element encodes the reference again, so a self-relative reference must still point within that element's root, and an in pool reference into its pool. A field or element of type T (T[4], struct Box<T> { x: T }) is a link. default<T>() of an optional relative reference is likewise the plain null, at any width.

self. A non-optional relative reference has no null, so a value whose links point back at itself (the sentinel of a circular list, the first node of a pool) could not be constructed at all: there is nothing yet for the links to point at. self, written as the entire initializer of such a field in a struct or variant literal, denotes the value that literal is constructing: Node { key: -1, prev: self, next: self }. It is legal only there, and only when the field's type is a non-optional relative reference whose pointee is the type of the value the literal constructs (for a variant literal in either enum mode, §3.5, that is the enum, tag included). self in a nested literal names the literal it is written in, never an enclosing one. That type must not be resizable: an offset alone cannot reach a resizable value's header. For the same reason, the same-root rule keeps every other relative reference away from a resizable value. In a self-relative field, the stored offset is minus the field's own byte offset within the value, so it is the only relative reference whose meaning does not depend on where the value is stored. In an in pool field, the stored offset is the value's own offset in the pool, which only a literal being built inside pool has: in a push or append, an alloc_index/alloc_ref, or an element store into the pool. Anywhere else, self in an in pool field is a compile error. This allows a structure to use non-optional links throughout. A sentinel eliminates the need for null links, and loading a non-optional relative reference requires only an addition, without a null test.

Which form. Use self-relative references for position-independent blobs, such as a compact tree that is saved, mapped or moved whole (single-byte links to nearby nodes, see A.2), and for structures stored in a local or parameter pool, which has no name to write. Use in pool for relink-heavy structures in a global pool, where a store is a subtraction from a base already in a register rather than from the field's own address. Use it also for links into that pool from other arrays, which self-relative references cannot express at all, and wherever the offset is wanted as an index, since &pool[i] encodes as i * sizeof(T) + 1 and index_of (§3.3) reads it back.

3.10 Slices

T[:] is a reference plus an element count, referring to a contiguous run of Ts inside some root. Slices are the universal "process a range" parameter type, unifying all array representations. Like references, slices carry a root and participate fully in the lifetime system (§9), which prevents dangling slices. They are the intended read path, and mutation idiomatically goes through references, but writes through a slice are legal when its provenance is writable (§9.5).

3.11 Index and bound types

An index type uN in a is an unsigned integer that is below a.len wherever it can be observed. A bound type uN upto a is an unsigned integer that is at most a.len: the end of a range. Indexing a with an index of a needs no check, and an index stored in data keeps that guarantee wherever it is copied to. This is how an index loaded out of one array (a graph's edge targets, a mesh's index buffer) is checked once, where it enters the program, rather than wherever it is used (docs/design/index_types.md).

struct Node { lo: u32 upto edges, hi: u32 upto edges }
var nodes: Node[>..] = [];
type NodeId = u32 in nodes;
var edges: NodeId[>..] = [];

4. Values, copying, and assignment

4.1 Everything is a value; passing by size class

Goose has value semantics: a variable, field or element owns its value inline, and a destination (an assignment, an argument, a return, a push, a field initializer) receives a value of its own. How a value gets there depends on its size class (§1.1):

So f(xs) passes a u8[][>..] variable by reference to each of fn f(xs: u8[][:]), fn f(xs: u8[][>..]&) and fn f(xs). out.push(w) needs to be written out.push(copy(w)) when w names storage. x.f(a) (§7.1) reads as f(x, a) whatever x's size class. At an untyped destination, a reference to a fixed value is still written &x (let r = &n;, or equivalently let r .= n;, §3.8; f(&n) into an untyped parameter). Wherever the destination's own type is a reference, the & is redundant, and a redundant & is a warning. An argument's & in a generic function's body, in a function value's body, and in what is declared in either is exempt, as a redundant cast is (§6.3): another instantiation may need it, as F(N, &value.A) over fields<T> (§6.5) needs it for a fixed-size field and not for a non-fixed one.

These destinations give an if, match, block, loop or bare { } no destination type: an un-annotated let/var, a result whose type is inferred (§7.1), an untyped parameter, an operand, or the value a for, [..] or builtin works on. There, the construct's value is a copy of what the branch taken gives, unless its branches join as a slice (§6.4). A reference to a fixed-size value (&x included) is copied as its pointee there, as it is wherever a reference meets a value (§3.8). let r = if c { &a } else { &b }; makes r a copy of a or b, so the &s are redundant and warn. A branch naming non-fixed storage, or a reference to it, is an error there, as it is at a value-typed destination; let x = if c { copy(a) } else { copy(b) }; writes the copies out explicitly.

At a reference-typed destination (an annotated let/var, a reference parameter), each branch binds by reference instead, whatever its size class. let r: i64& = if c { a } else { b }; binds a or b itself. f(if c { a } else { b }) passes a or b itself to fn f(xs: i64[>..]&), as g(if c { i } else { j }) passes the i64 i or j to fn g(x: i64&). Given if c { s } else { t }, fn h(p: u8[:]&) re-points the slice variable the branch taken names. Calling a function value is a call, not one of these constructs: its value is a temporary even where its body's value names a variable (§9.2).

There is no ownership transfer beyond the return move, no destructors, no Drop, no reference counting. The only deallocation is scope exit resetting stack pointers. A defer (§6.6) runs code the program writes at a block's exit; it is tied to the block, not to a value, and releases no memory itself. (A move operation for resizable arrays, which assigns and leaves the source empty, is anticipated but not in v1.)

Copying a variable or resizable value costs O(size). The explicit copy marks that cost in the source.

Shadowing: an inner scope may re-declare a name (a distinct variable).

4.2 Construction contexts

Variable and resizable values come into existence only at these points, each of which provides fresh storage in a statically known place:

Construction writes metadata first, then elements or fields in order, as required by invariant §1.3(4). Construction of a limited array writes only its metadata and any provided elements; the remaining capacity is reserved but uninitialized. This is safe because no read path to uninitialized slots exists (§5.3), and cheap because the runtime commits skipped address ranges explicitly (Appendix C.4).

While a value is being built in place in an array, nothing may grow that array, neither the expression being built nor a function it calls (§1.3(4)). Such a value is an element pushed into or allocated in the array that is variable-size or holds relative references, a call's array result appended to it, an appended array literal of such elements, or the new contents of a whole assignment (§4.4). The compiler rejects a growth it cannot show to be of a different array. A whole assignment builds its new contents over the old ones, so its right-hand side may not use the array at all (§4.4).

Literal forms usable in any construction context:

4.3 The in-place construction guarantee

A constructed nonfixed value is built directly in its receiving storage, including through calls and the selected branch of an if/match/case-dispatch. Passing through such an expression is not a reason to materialize a separate result and then copy it into place.

The same guarantee applies to fresh fixed-size aggregates containing relative references when the receiver takes their representation. Their result channels name the receiving storage, including through multiple and nonlocal returns; correctness does not depend on inlining or C compiler optimization. Other fixed-size results may be passed as ordinary C values.

The packed byte layout may require relocation within the receiving storage: a length prefix whose width becomes known after constructing the elements can grow or shrink, and a value-form result delivered as an element run can lose its header and unused capacity. Moving the element bytes for these layout changes is permitted. The final representation, reference targets and evaluation order must remain the same; an initializer must not be re-executed to discover its size.

A limited-capacity receiver must establish that the complete live result fits before writing outside its available slots. When the result's length is not available before evaluating it, temporary construction for this capacity check is permitted. This exception concerns a bounded receiver; it does not permit staging an ordinary fresh result for a resizable one.

Copying an already-constructed value (an explicit copy, a by-value argument from fixed storage, or the repeated value of a fill) is an ordinary copy. The guarantee concerns fresh construction; §7.3 extends it to returned locals and specifies the copies associated with different returned locals and exits that abandon a partial destination.

4.4 Assignability

An lvalue may be assigned (=) after construction iff its size cannot change or it can absorb the change (for reference-typed lvalues these rules govern the pointee write that = performs; the reference value itself is updated only by .=, §3.8):

The right-hand side of a whole assignment of a resizable runs after the old contents are gone, while the new contents are being built in their place. So it may not use the array: it may not name the array (including its length), or a reference to the array or to the value holding it. Neither may a function it calls, through a global or captured variable that function names. x = [x[1], x[0]], x = f(x) and g = rebuilt() for a rebuilt that reads g are errors. As with growth (§4.2), a use the compiler cannot show to be of a different array is an error too. Examples are a use of a reference parameter that a caller may bind to the global being assigned, and a use through a call into a recursive cycle still being checked, which counts as using every global and every variable in scope.

Where the assignment names the array as a field (t.chars = …), the right-hand side may still name those other fields of the holding value that cannot lead back to the array. These are flat fields (§1.1), and fields whose references, through any number of steps, lead only to storage of a type that cannot be the assigned location, cannot be a resizable on the way from that location to the array, and cannot hold the value the location is a field of. t.chars = render(t.font) is fine. So is s.path = find(s.start) through s: Walk& for struct Walk { start: Cell&, path: Pos[>..] }, and so is a reference of such a type that is bound to a variable first. A field last: Walk& or other: Pos[>..]& may refer to s or to s.path, so naming such a field is an error.

An assignment through a reference field (d.r = … for r: u8[>..<]&) writes the array the field refers to. That array may lie anywhere an array of its type can, including in the holding value's other fields. So a field that is, or holds by value, an array of that type may be the one assigned (d.r .= d.own), and so may what another field refers to. Naming either is therefore an error, except for a local whose record shows where the reference field may point.

Slices and references into the old elements are checked by the shrink rule (§5.1). The assignment starts with a shrink and the right-hand side runs after it, so a slice or reference that the right-hand side uses is live at the shrink. New contents computed from the old are built in a variable of their own and assigned as copy() of it, with the single copy written out explicitly at the point where it happens (§4.3).

let forbids only assigning that name or field as a whole. The contents of a let array or struct are as writable as their type says (let xs = [1, 2, 3]; xs[0] = 9; is fine), and so is what a reference to it reaches. Contents are made read-only by the type, const T (§9.5), and const x = e; declares a let of type const T, which can be neither reassigned nor written into. A var of type const T is the converse: it can be reassigned (=, compound assignment, ++) but never written into. Definite assignment is enforced: no reads of uninitialized locals; every declaration either has an initializer or is provably assigned on all paths before use. A let declared without an initializer gets its value once. Assigning it, or binding it with .= (§3.8), is an error if any path may already have given it a value. That includes a joining branch, an earlier iteration of a loop outside which it was declared, or a function called along the way. Calls that compute the assigned value also count (§7.5). A recursive cycle (§7.8) cannot assign such a let declared outside the cycle, since all the cycle's activations share it. Fixed arrays require full initialization (every slot is indexable); the [v; n] fill literal makes large ones cheap.

4.5 Equality and comparison

==/!= are structural and require both operands to have the same type. They compare:

References and slices follow a top-level rule. As the direct operands of == they have value-like semantics: a reference compares its pointee (transparency, §3.8), and a slice compares length, then elements. As members of a compared composite they compare by identity (the reference address, the slice's address+length): recursing through them would turn == into an unbounded pointer traversal. Optionals compare as nullable references (o == null is the null test); .==/.!= compare two references by address rather than by pointee (§3.8). Ordering < <= > >= exists on the numeric types only, with operands unified per §6.1.


5. The resizable/shrink rules

5.1 Grow-only [>..]

A grow-only array can shrink, but never while a reference or slice into it can be live. Memory below the top never moves and is never reused while the owner lives. Every interior reference and slice therefore stays valid for as long as it can be named, and a bound on the length holds across every push.

Use grow-only arrays for arenas, pools, string builders, tree storage, and scratch buffers that are refilled or popped between phases.

When a grow-only array may shrink. The receiver is the array's variable, a reference variable or parameter bound to the whole array, a global, or the tail field of a struct reached through any of those (s.items.pop() for s: Stack&). Shrinking a tail field shrinks the storage the struct lies in, as a reference to the field would. The receiver is not a temporary, and not a reusable pool (§5.4: its freelist keeps every slot live). The shrink may be anywhere in an expression (an operand, an argument, a condition, the value of a block, if, match or loop), so print(a.pop()) and if !load(s) { … } are fine.

At the shrink, nothing live may refer into the array. This rules out anything that the shrink's statement evaluated before the shrink and uses after it (§2): an earlier argument or operand that is, or holds, a reference or slice; an earlier field or element of a literal being built; the array an indexing or slicing reads; the destination of an assignment. So use(a[0], a.pop()), a[a.pop()], S { r: a[0], n: a.pop() } and a[0] = a.pop() are errors, while a[0] + a.pop() reads its left operand first and is not. Nor may a value be under construction in the array's storage (§1.3): an element that push builds in place (§4.3), a literal run that append builds, or a call's result being appended.

It also rules out any reference or slice variable rooted at the array. This includes a var reference that the same-depth rebinding rule (§9.2) could retarget into the array, and one that is not bound yet but is bound further down a loop body. And it rules out any value that holds a reference into the array: a struct with a reference field, an array of slices, a let copy of an element of such, and the struct whose tail the array is, through its other fields. Values hold references only where a store put them, and every store the checker has seen is on record (§9.2). So this half of the test is exact to the store: the error names the holder and the line where a reference into the array was stored into it.

A value is never a holder if its type cannot hold a reference to anything the array's elements contain by value, through any number of references. Nor is a value linked by relative references alone (a node pool), since relative references point within their own root. A reference to a whole array or table is not a reference into its elements, and a shrink leaves it pointing at the array or table: struct Walk { start: Cell&, text: u8[>..]&, path: Pos[>..] } holds nothing a shrink of path frees, and w.path.clear() through w: Walk& may be followed by any use of w. A view of the elements taken through such a reference is stored, if at all, where the holder's type has room for one. The same holds after a whole assignment: s.r = [1, 2] for r: u8[>..]& may be followed by any use of s. (The right-hand side of a whole assignment is different: it runs while the array is rebuilt, and may not reach the array at all, §4.4. A variable that the right-hand side takes a reference out of counts as holding what that reference leads to, but a variable of which it reads only flat fields does not.) A reference to a slice variable or to a holder (including the storage whose tail shrinks) counts as what it refers to: while the reference is live, so is whatever that slice or holder may refer into.

A whole assignment of a value that holds no references into storage (refs = [], s.refs = [], a struct literal with none in it) empties the part of the holder it writes: what was stored there before is gone. A null and a string literal are static data, which no shrink frees, so s.name = "", s.r .= null and a struct literal whose reference fields hold only those empty their parts as well. The part is a variable's own storage, or the pointee of a let reference or of a reference parameter (which no rebinding moves), together with the fields named after it, down to a slice's or a reference's own slot. Where each slot of a holder that can hold a view into the array lies, on every path to the shrink, in a part emptied this way (the whole holder, or its fields one by one), the holder holds only what was stored into its storage since then. This stops being true once a store into that storage is made that the record cannot show, such as one moving the holder's own references within it. It also stops once a call runs that may write into the part, since a callee's stores are never on record in their order. These calls may write into the part:

A call handed a struct of the function's own, built from the holder's reference fields, whose references lead only to what those fields do, is not such a call. So for i in 3 { refs = []; pool = []; refs.push(pool.push(i)); } passes: the store at the end of one iteration is gone by the shrink at the start of the next. Emptying refs on some paths only, after a store that reaches the shrink, or only in a copy of it, does not pass. s.refs = []; s.pool = []; s.refs.push(s.pool.push(i)); through s: S&, struct S { pool: i64[>..]&, refs: (i64&)[>..] } also passes: the path to the pool is no reference into it, and a view taken through it is stored, if at all, in refs.

A shrink must also preserve the storage that an enclosing for loop walks (§6.5). This includes every reference or slice on the path to the sequence, since the loop reloads that path on each iteration. It also includes the sequence iterated in place, such as a slice, element, or field. A resizable array is the exception: the loop reads its length again on each iteration.

A variable is live at the shrink when it can be read again afterwards, that is, when it is named:

But suppose a statement of the shrink's own block after the shrink's statement is a return or a break, and neither the shrink's statement nor any statement between them has a continue that could lead back into a loop. Then nothing that the exit leaves runs again from the shrink. The exit leaves the rest of that block; a return also leaves the rest of the function's body, and a break the rest of the loop or block it ends, including that loop's next iteration. So a decoder may result.clear(); return result; inside its loop while a dictionary of views into result that the loop fills is still in scope. The exit's own statement is read after the shrink like any other.

A function value's body runs in the middle of a statement of the function calling it (§7.6), so that function's variables count as well. That function may also call the function value again, so a variable declared outside the body counts wherever the body names it, as in a loop. Only code that can name the variable counts (the code of its own function, and of the nested functions and function values written in it), so the same name in another function is a different variable. "Named" is syntactic: any mention counts, including a call of a nested function that mentions the variable, so the test never depends on what the optimizer proved.

A reference whose last use is before the shrink is dead, even if its block has not ended: let w = line[..5]; print(w); line.clear(); is fine. A scratch buffer refilled per iteration, or a stack popped between phases, can hand out slices of itself freely, so "reusable scratch" and "structure I can point into" are the same type. The operations themselves are the grow-shrink ones: a stack-top move and a length store.

Assigning the array whole (a = …), or assigning a value holding it, replaces its elements. It is a shrink under the same rule wherever its location could be the receiver of a clear: for s: Stack&, s.items = [], s.items = load(f) and s = fresh() are shrinks of the storage s names, as s.items.clear() is. The right-hand side runs after the shrink, so a variable that side names is used after it. print, str and format evaluate and render their arguments in order (§3.7). So a variable that a later argument names is used after a shrink made by an earlier argument, whether by a call in that argument or through a format overload rendering it.

A shrink through a reference or of a global cannot see the callers' variables, so it is checked at every level. Each function specialization records which globals, and which parameters' pointees, it shrinks (directly or through its own callees), and what its calls stored into those pointees. A call is checked as if it performed those shrinks and stores in the caller, with each grow-only shrink checked where it stands in the body:

A function doing s.refs = []; s.pool = []; s.refs.push(s.pool.push(i)); may be called again and again on the same holder, in a loop or through a function taking the holder by reference. The pointees of a parameter that holds references by value (a struct with a reference field, an array of slices) are what those references point to: a shrink or a store through one of them is of the caller's storage, not of the callee's copy of the holder.

When a global receiver shrinks, the other globals are live, and what is stored into them may come from functions checked after the shrink. So they are judged once the whole program has been checked. By then every store into a global, and every binding of a global reference or slice, is on record, wherever in the program it is made. The shrink is an error where another global may hold a reference into the array; the error names that global and the line of the store. A call into a recursive cycle still being checked counts as shrinking every array a function of the cycle textually shrinks.

A user format overload (§3.7) is a call at the print, str or format that renders a value through it. When a format call renders the value, the text is appended to its receiver, and that receiver is the overload call's builder. The rest of the argument holding that value is rendered around the call, so it is still in use there (where the argument lies, the elements of the arrays and slices it renders, and what its references lead to), unless the overload takes the whole argument: the value itself or a reference to it.

A reference whose root is inexact (§9.2), such as a value chosen between arrays (if c { a } else { b }) or one read back out of a container, may point at any array of its type that the root bounds. A shrink through it counts as a shrink of each of those arrays: those in variables at the root's scope or outside it, and the caller's arrays that a parameter's references lead to, for which the callers check the shrink. The same holds where a call shrinks what an inexactly rooted argument points at.

A reference read out of the storage behind a reference parameter (s.text = … for s: S&) may point at anything the callers or the function stored there. The function judges its own variables against every array of the type that outlives the call. Its callers each judge the shrink, by their argument, as a shrink of an array that the storage leads to. A struct of the caller's leads to the arrays its record says it refers to that are of the type shrunk, or that hold or lead to one: s.text = copy(t) in a callee frees no global u8[>..] the caller views, where the caller's struct refers to a local text and a node pool. A struct built from the caller's own struct parameter's reference fields leads where those fields do, which the caller's callers judge in turn.

Nor can a shrink inside a function tell what its parameters point into. A parameter's pointee is whatever each call passes, which may be a global, a variable of an enclosing function, or what another parameter points into; an inexactly rooted argument may be any array its root bounds. Where a reference, slice or holder parameter, or a variable bound from one, is still used after a shrink of a global, of such a variable or of another parameter's pointee, the specialization records the pair. It does the same where a view of a global or of such a variable is still used after a shrink of a parameter's pointee. Each call judges the pair by the arguments it passes. A call that may make the two one array is an error, as a still-used variable of the caller would be, and a call that passes its own parameters on records the pair for its own callers. A call into a recursive cycle still being checked is judged again against the pairs the cycle records, once the whole cycle has been checked.

5.2 Grow-shrink [>..<]

5.3 Limited arrays [..k] / [..]

Limited arrays grow and shrink freely within a fixed capacity. They are not bound to a stack top (their capacity is reserved at construction), so they can live anywhere fixed or variable values live ([..k] is fixed-class, [..] variable-class). A T[..] constructs either from element contents (capacity = initial length) or with the [..cap] literal (capacity cap, length 0).

Interior references and slices are allowed, including surviving pops. Because capacity and element type are fixed, every slot within capacity, once first written, remains a valid value of the element type forever; pop-then-push reuses memory at the same type. Never-written slots are unreachable: indexing checks len, references/slices can only be created into [0, len), and every growth operation supplies values (§3.3). A stale reference reads a valid, possibly-different value ("type-safe reuse", §9.4), never corrupt memory. Indexing through the array checks against current length; access through retained references does not (they were validly created).

5.4 reusable arrays (safe allocation escape hatch)

Deprecated: use the standard library's slot_pool<T> for slots and slice_pool<T> for contiguous runs. The builtin syntax and behavior described here remain supported for existing code and the remaining migration gaps. Removal is a separate future step; no deprecation diagnostic is emitted yet.

A grow-only resizable local of fixed-size elements may be declared reusable (the keyword prefixes the declaration: reusable var pool: Slot[>..] = [];, also valid on globals). The compiler pairs it with a hidden freelist (itself a small resizable of indices, on its own stack, counted in N):

This is part of the semantics, not just the implementation: all elements remain valid at all times. A reference to a freed-then-reused slot reads a different (same-typed) value. This is type-safe reuse, never memory corruption. This is the language's answer to tree-mutation workloads that would otherwise need an allocator.

Slice pools. Declared reusable[] (reusable[] var pool: T[>..] = [];, also valid on globals), the array hands out runs of elements instead. Its hidden freelist holds (index, count) spans sorted by index, and a freed run merges with the spans it touches:

Every element an allocation or a growth adds is a default value (§4.2), so those two need an element type that has one. The length is evaluated once, and a negative length, or one no data stack could hold, aborts before the pool changes. The slice handed to free_slice or realloc_slice must be one of the pool's runs: one the pool handed out, a re-slice of it, or a slice of the pool itself. Where the checker sees that, because the slice is rooted at the pool exactly (§9.2), nothing is checked when the call runs. Where the pool is a global or a variable of the calling function, or a pool reference that can only be one of several such (Pool references below), a slice rooted exactly at a global or a variable of the calling function that the pool cannot be is a compile error. Any other slice, such as one read out of storage where runs of other arrays could be kept as well, is checked when the call runs: unless it is empty, its elements must lie inside the pool's length starting on an element boundary, or the call aborts before the pool changes. An empty slice frees nothing and grows like a new run, wherever it points. A grown slice may be copied, which a value holding self-relative references cannot survive (§3.9), so realloc_slice is a compile error for such an element type; in pool references copy fine, and the other two operations never move an element. The single-slot operations do not exist on a slice pool, nor these on a reusable one; push and append add at the end of either without consulting the freelist.

The guarantee is the same: a slice still naming freed elements reads whatever their next owner wrote, or the default values an allocation put there. Freeing a run twice is a logic error of the same kind: the freelist can then hand the same elements out twice, but never anything outside the array.

Pool references. A T[>..]& variable or parameter bound to a pool is a pool reference: the pool's operations work through it as on the pool, since it carries the pool's freelist beside the address. So is a variable or parameter bound to a value that chooses among pools of one kind, or among pool references to them (an if, a match or a block giving one wherever it gives a value). It carries the freelist of the pool chosen. A choice between the two kinds of pool, or between a pool and another array, binds a plain reference. A variable is a pool reference from its first binding on, and .= rebinds it only to a pool of the same kind, or to a pool reference to one; pointing it at any other array needs a new variable. A variable first bound to anything else stays a plain reference, which .= may point at a pool, as at any array, but which offers none of the pool's operations. Only a plain reference variable or parameter is a pool reference: an optional one, a field, an element and a function's result are not, even where they point at a pool.


6. Expressions and arithmetic

6.1 Operators

The operators follow C and Rust:

Precedence follows Rust (Appendix D). Bitwise operators bind tighter than comparisons, so x & mask != 0 means (x & mask) != 0. Shifts bind tighter than &. Range expressions a..b appear only in slicing brackets, for headers, and match arms.

Operand unification. A binary numeric operator's operands must reach one common type, which is also the result type. It is found as follows:

A float that takes its type from float literals (the literal itself, or n * 0.5 built from literals and integers) adapts to the other operand's float type as a literal does (x * (n * 0.5) with x: f32 computes in 32 bits throughout), and is an f64 where neither operand gives it a type. Anything else (same-width signed/unsigned, u64 with anything signed) is a compile error asking for a cast. Nothing here invents a type absent from the expression: u8 + i64 is an i64 add, but u32 + i32 does not become 64-bit arithmetic implicitly.

Exceptions: shifts take the left operand's type as the result (the count is any integer type, masked per §6.2), and ==/!=/orderings unify the same way but produce bool. So a constant shifted by a count that is not a constant keeps the constant's freedom to adapt. 1 << k is not a constant, but it takes the integer type of its destination or other operand, as a constant does, where the constant fits that type (let m: u64 = 1 << k;, m | (1 << k), a u8 field), and is an i64 where nothing gives it a type. An integer operation over such shifts, constants and constructs whose branches are all integer constants (§6.4) does the same ((1 << k) - 1, ~(1 << k), (if c { 1 } else { 0 }) + 1), and is computed at that type throughout; a typed operand, unary - or a cast gives it a type of its own. So does ~c of a constant c >= 0, whose value depends on the width it is computed at. It adapts where c fits, so let a: u8 = ~4; is 251 and flags & ~F_ROAD clears a flag in a u8, while ~4 is -5 at a signed type or where nothing gives it a type. -~c is the constant c + 1, and a named constant ~c (§3.1) adapts as ~c does. Unary - requires a signed (or float) operand; ~ any integer, keeping its type.

Comparisons and u64. A comparison produces bool, so it does not need a common numeric result type. However, comparing signed and unsigned operands must preserve their values. u64 is the only unsigned type with no signed supertype (u8–u32 widen into i64, §6.3). A comparison between u64 and a signed type is therefore allowed when the signed operand is known non-negative: it converts to u64 without changing value, and the comparison is a single unsigned one, no wider than either operand and never a hidden branch.

"Known non-negative" is determined by syntax and is limited to a non-negative integer literal, a .len or .cap (non-negative by §10.4), or a let bound to one of those. A let is never reassigned, but a writable reference can still change its value (§4.4). A comparison therefore cannot rely on a let if it, or a let it was bound to, is bound to a writable reference anywhere. This includes bindings through &, .=, or a reference parameter, field, or binding that is not const. Bindings in nested functions count too. Either order is an error. Whether a comparison compiles thus depends only on what is written, never on how much the optimizer managed to prove. Other cases require an explicit cast because conversion may change the value. A cast does not, however, preserve every comparison: x as! i64 on a u64 above i64.max silently compares as negative, and the checked x as i64 aborts in debug on a value that was perfectly legitimate to compare. When the rule permits it, use the direct comparison to preserve the values without conversion overhead.

Decided comparisons. An ordering comparison of an integer with an integer constant (a literal or a named constant, §3.1) that the integer's type alone decides is a warning naming the type's range. Of a u32 or an index type e, e < 0 and 0 > e are always false and e >= 0 always true; so is b <= 255 of a u8, and x < -128 of an i8 is always false. A constant the type cannot hold is an error instead (b > 300 of a u8), and a u64 is judged at 0 only. Comparisons in a generic function's body, in a function value's body, and in what is declared in either are exempt, as redundant casts are (§6.3): another instantiation may need them.

Elementwise math: the arithmetic operators apply memberwise to any two values of the same struct/fixed-array type whose scalar leaves are all integers or all floats, none of an index or bound type (§3.11); the result has that same type. The compound forms += -= *= /= %= apply to these same types, resolving the destination once and reading its old value before the right-hand side runs (§2). Unary - negates such a value memberwise where no leaf is an unsigned integer (-v, with the same result type; a float member negates exactly, so 0.0 becomes -0.0, and an integer one overflows as a scalar - does). This covers vector math without an operator-overloading feature. The standard library supplies float3 and friends; named vector ops (dot, cross, normalize, …) are ordinary stdlib overloads per math type, not language builtins.

* and / also accept one scalar operand, in either order, when every numeric leaf of the struct/fixed array has exactly the same type. The scalar converts implicitly to that leaf type (§6.3): float3 * 2.0, 2 * float3, and float3 / f32 produce a float3; 2.0 / float3 divides the scalar by each component. The aggregate's type never widens: an f64 variable scaling a float3 needs as f32, and an integer scalar must fit or widen into the integer leaves. Nested structs and fixed arrays work the same way; aggregates with mixed leaf widths or kinds, or no numeric leaves, do not support scalar operands. +, -, and % still require two matching aggregates. The compound forms aggregate *= scalar and aggregate /= scalar follow the same rule. Both operands are evaluated once, left to right, before any result member is written, including when the scalar is a member of the destination.

6.2 Integer semantics

6.3 Conversions

Within a kind, a conversion the machine can prove value-preserving is implicit, and anything that could lose a bit or flip a sign needs a cast. Between the kinds, an integer becomes a float implicitly, and a float becomes an integer only through a cast: dropping the fraction is the real loss of information, while an integer too large for the float's mantissa rounds as every float result does.

Redundant casts. An as or as! that changes nothing is a warning. Such a cast is one whose operand has its type already, or converts to it implicitly where the cast stands, so that deleting the cast leaves every type, operation, value and call of the program as it is. n as i32 of an i32 n warns, as do 17 as u32 passed to a u32 parameter, b as i64 of a u8 b stored in an i64, and w as f32 in (w as f32) * 0.5 passed to an f32 parameter, where w * 0.5 is an f32 multiply too. A cast that decides anything stays silent: a type nothing else gives (let k = 5 as u32;, print(b as u8)), an operation's width or signedness ((a as i64) * b with i32 operands), a float's rounding ((n as f32) * 0.1 stored in an f64), a call's overload or a generic's instantiation. Of two casts that cannot both be removed, as in (a as f32) * (b as f32) of two integers, the right one warns. Of a cast directly inside another, where both could be removed, the outer one warns. Casts in a generic function's body, in a function value's body, and in what is declared in either are exempt from this warning: another instantiation may need the cast. The warning comes once per cast as written, where every check of it agrees.

6.4 Control expressions

if c { e } else { e }        // expression; both arms required when used as value
while c { s }
for v in e { s }             // see §6.5
loop { s }                   // infinite; exit via break
block { s }                  // early-out construct: break E exits with value E
{ s }                        // a plain scope with a value; not a break target
guard c else { s } …         // if c { … } else { s }: … is the rest of the block
guard c; …                   // if c { … }
match e { ... }              // §8
return e? (from f)?          // §7.3, §7.9
break e?                     // exits innermost loop/block, optionally with value
continue

block { } exists to promote early-out style anywhere, not just at function top level. break binds to the innermost loop/while/for/block; labels are not in v1. For definite assignment (§4.4) and narrowing (§3.8), what holds after one of these is what holds at every way out of it: each break, and the end of a block's body, a while's condition found false or a for's last iteration; a loop has only its breaks. All break E of one construct must agree on E's type: a later break's value constructs into the type an earlier one gave the construct.

A construct's branches (its arms, its tail and its breaks) join into one type. A literal branch adapts to the others' type, as at any typed destination (§3.1). An integer constant adapts to an integer type it fits (if c { x } else { 0 } has x's type, a u8 for an x: u8), and a float literal, or a float of literals and integers (§6.3), adapts to an f32 (if c { x } else { 0.62 } is an f32 for an x: f32). An integer branch beside a float one converts to the float's type (§6.3): if c { n } else { 0.5 } is an f64 for an integer n. A [] branch adapts to no other branch: it takes an element type only from a destination (§4.2).

A construct whose branches are all literals is itself literal-like wherever it meets a type, as at a typed destination:

A break with such a number settles no type for the later ones: loop { if c { break 1; } break 2.5; } is an f64. A bare { … } in expression position (a match arm of several statements, for example) is only a scope: break inside it still leaves the enclosing loop.

Arrays and slices of one element type join as a slice. This happens where one branch's value is a slice (a string literal, for example) and another's is an array of the same element type, or where the construct's destination type is such a slice (an annotated variable, a declared result, a parameter declared as a slice). An array that a branch gives is then a whole-array slice of that array itself (§3.10) rather than a copy of it (§4.1): let name = if item.count == 0 { "none" } else { item.name }; makes name a const u8[:], which views the u8[..48] item.name where that branch is taken. The slice is read-only where any branch is: a const slice (a string literal among them), or an array a view of which would be (§9.5). Like any slice it must not outlive what it views (§9.2): an array declared in the branch, or a temporary made there (a call's array result, an array literal), is an error. Arrays of two different types join this way only where a slice joins them as well, or where the destination is a slice; otherwise they do not agree. A break agrees with the first: after a slice, a break's array is a slice of itself, while an array that comes first is what a later slice constructs, where it can.

guard guards the rest of the block it is in, whatever kind of block that is: a function's body, a loop's body, a branch, a block, a plain scope, or a function value's body (§7.6). So { s1; guard c; s2 } is { s1; if c { s2 } }, and { s1; guard c else { s3 } s2 } is { s1; if c { s2 } else { s3 } }. The code after a guard runs only where c holds, and is checked on that basis, including flow-narrowing of T? (guard r else { return; } leaves r: T&). A guard does not jump anywhere: guard c; skips the rest of its block. Directly in a function's body this returns from the function, in a loop's body it goes on to the next iteration, and in a function value's body it ends that call of the value, not the function running it. Leaving more than the block is the else block's job: guard c else { break; } leaves the loop, guard c else { return; } the function. An else block that does not leave runs instead of the rest of the block, which then ends. Where the rest of the block ends in a value, the if is the block's value (block { guard ok else { break 0; } n * 2 }); a guard is a statement of its block, never an operand. What is declared after a guard is declared in the if's block, so a nested function declared before the guard cannot call one declared after it (§7.5).

6.5 for

Built-in iteration only (no iterator protocol):

All array-family types and slices are iterable. Custom access patterns are provided by HOFs taking static function values (§7.6), which compile to plain loops.

Range/count bounds are evaluated once, before the first iteration; an empty or reversed range performs no iterations. continue advances to the next index or sequential element just as reaching the end of the body does.

A loop iterates its sequence in place, not a copy of it. Traversal of a resizable array tests the current length before each iteration, so permitted growth can add elements to the traversal and a shrink ends it early; its elements never move. A slice keeps its own length: growing its source does not extend that view. A slice, and anything else the loop walks in place (a fixed or limited array, an element or a field of a larger value), is live for the whole body, as a slice variable would be: a shrink of a resizable array it lies in or views is an error, however the body reaches it (a call, a function value; §5.1, §5.2). An index on the path to the sequence is evaluated once, but the references and slices on it (refs[i].items) are loaded again on every iteration, so the arrays they lie in may not shrink either. Iterating a copy (let row = a[i]; for x in row over fixed-size rows), or a reference read out first (let r .= refs[i]; for x in r), leaves the array free.

Specialization-time expansion. const for specializes a fragment once per entry of a compiler-known sequence. Its v1 sources are fields<T> with two bindings (field name string and field accessor), variants<E> with two bindings (variant name string and variant), types<T1, T2, ...> with one type binding, and [c1, c2, ...] with one constant binding. fields, variants and types are contextual identifiers in this header, not keywords or ordinary function calls. Bindings use required angle brackets:

fn visit_fields<T, F>(value: T&) {
    const for <N, A> in fields<T> { F(N, &value.A); }
}
fn transform_fields<T, F>(value: T&) -> T {
    T { const for <N, A> in fields<T> { A: F(N, value.A), } }
}
fn each_case<T, F>(value: T) {
    match value { const for <N, V> in variants<T> { V c => F(N, c), } }
}

fields<T> visits direct, non-padding fields of a concrete struct or variant type (a variant's payload) in declaration order. Its accessor identifies the field of that particular struct instantiation: another struct with the same field name does not match. A variant's accessor reaches a value of that variant type only, not the ADT before it is matched. It can appear in a member access or initializer label. variants<E> visits every variant of a concrete ADT in declaration order, with or without payload; its variant binding is written where a variant's name is: a type, a constructor, a match pattern with or without a binder, and, for a variant without payload only, a value. It is not stored, passed or turned into a number, and its name string is not a persistence identifier. Bindings follow lexical scope and shadowing, including in nested functions. Types and constants may be heterogeneous; all three sources may be empty. Constants are scalar or string literals, named global constants, and the integer constant expressions supported for array sizes, including enclosing expansion bindings. There is no arbitrary compile-time execution.

A statement fragment is a separate scope for each entry. A constructor fragment contributes comma-separated initializer entries, flattened before ordinary default, completeness, ordering and destination checks. A match fragment contributes arms, flattened before the checks of §8.1: every variant once, _ last, and the reference-binder and lifetime rules for each generated arm. A generated arm may bind a variant without payload, to an empty value of its variant type. An expansion produces no runtime iterator or reflection metadata. Its body contains ordinary runtime code, with the usual reference, lifetime and construction rules. Returns retain their lexical targets; a break or continue cannot cross an expansion boundary. The full v1 rules and implementation limit are in the const for design.

Specialization-time selection. const if chooses one of its fragment lists while a function is specialized, and generates nothing of the others. It stands where a const for stands, in a statement, constructor initializer or match arm list, with fragments of that list's category in each branch; a chain continues with else const if, and a plain else if after a const if is an error. Its condition is a constant of the specialization: true, false or a named bool constant; a query; !, &&, || and parentheses over conditions; or a comparison between two constants of one kind, the constants a const for list accepts. Integers unify as constant arithmetic unifies them and compare at that type; an integer beside a float converts (§6.3); booleans and strings take == and != only. A value computed at runtime, a local, a literal parameter (§7.7) or a field read is an error, never a runtime branch. The queries, valid in a const if condition only, are has_default<A>, true exactly when the field an accessor A from fields<T> stands for declares a written = ..., and same<T1, T2>, true when two types are identical after substitution, qualifiers included. The names has_default and same are contextual there and identifiers elsewhere.

fn missing<T>(seen: bool[:]) {
    var i = 0;
    const for <N, A> in fields<T> {
        const if !has_default<A> { if !seen[i] { print("missing ", N); } }
        i++;
    }
}

A branch that is not chosen is parsed and name-bound but never checked, as a const for body over an empty source is not: a call that does not exist or a write a field's type forbids there is not an error. In a statement list the chosen branch is a nested block of its own scope and not an expansion boundary, so break, continue and return in it mean what they mean in an if's branch, and what follows is checked for the branch generated. In a constructor the chosen entries join the initializer list, and in a match the chosen arms join the arm list, before the checks of §4.2 and §8.1. The design is section 11 of the const for design.

6.6 defer

defer stmt;                  // an assignment, an increment, or an expression statement
defer { … }                  // a block

A defer statement's body runs when the block the statement is written in ends, by any exit: the end of the block's text, break, continue, return, a function value's return (§7.6) through the call running it, or a return … from (§7.9) passing through the function. It runs once per exit of the block, so a defer in a loop's body runs at the end of every iteration. An exit runs the bodies of the defer statements it has run past in the blocks it leaves, innermost block first and latest first within a block, so a body runs before the storage declared before it is released and may read it. The body is evaluated at the exit, not where it is written: it reads variables as they are then. At a return, the returned value is constructed at its destination first (§7.3), then the bodies run, then the block's storage is released.

Restrictions, all checked statically:

A defer body does not run where no exit happens: abort, a failed assert or bounds check, exit, a contained worker's abort (§11.2), and workers still running when main returns. The process or the worker's end reclaims what the body would have released.


7. Functions

7.1 Declarations and generics

fn name(a: i64, var b: f64, xs: i32[:]) -> i64 { ... }
fn generic<T>(a: T, b: T) -> T { ... }
fn also_generic(a, b) { ... }        // untyped params are generic
fn scaled(a: i32, b: i32 = 0, c: f32 = 1.0) -> f32 { ... }   // scaled(2) is scaled(2, 0, 1.0)

Declarations at top level are order-independent (whole-program compile); only global initializers have ordered semantics (§11.1).

7.2 Parameter passing

Parameter passing works like = (§4.1): the parameter's type is the destination, and the argument connects to it by size class. A fixed-size argument is copied into a by-value parameter and bound by a reference-typed one (fn inc(n: i64&) is called as inc(n); inc(&n) means the same and draws a warning). A non-fixed lvalue argument binds by reference to a reference, slice or untyped parameter, and is an error for a by-value one. f(copy(xs)) writes the copy out explicitly, and f(make()) constructs the result in the parameter's slot (§4.3). Array→slice conversion at call sites (§3.10) is the only change of shape; it is cheap and never copies. For a by-value non-fixed parameter, the call site reserves the statically assigned stack region for the value and constructs the value there, and the callee owns it like any local. A parameter that the call leaves to its default (§7.1) receives the default in the same way, as if it were the argument written in its place: xs: T[>..] = [] builds an empty array in the slot, and r: i64& = counter binds the global.

7.3 Return values and result placement

Normal completion. The checker infers whether each function specialization can resume its immediate caller. A body whose paths all terminate, loop without an exit, or return ... from an outer function does not resume that caller; this propagates through ordinary calls and static function values. A variant-dispatched call has this property only when every case does. A return targeting the function itself, including one taken by a nested callee, resumes its caller normally.

A function declared to return values needs no result on a path ending in such a call. For example, fn fail() { abort("failed"); } permits fn value() -> i64 { fail(); }. This is a control-flow fact, not a value type: fail() still produces no value to pass as an i64 argument or to forward in an explicit return fail();.

After a proven exit, the remaining statements and trailing expression in that block are neither typechecked nor emitted. They still must parse. This does not make checking depend on constant folding: both branches of if false { ... } else { ... } are checked. Unknown recursive calls are assumed to return, so inference may miss a recursive function that never returns; it does not skip a continuation merely because the recursive callee has not yet been checked.

Fixed-size returns use registers/native stack or a caller-owned data-stack slot for large values (C.3). A variable or resizable return value is constructed directly in its destination (§4.3). The callee is compiled knowing the destination stack (statically or as a hidden argument), writes element data there, and returns the value's metadata (lengths) outside the element run. Where a result is first built with its packed value header, the header is removed in place under §4.3's relocation exception (Appendix C.3):

Named results (guaranteed NRVO). When every return of a nonfixed result returns the same local variable (and those returns are its last uses), that local is allocated at the return destination from its declaration, so return x costs the same as returning the constructing expression directly. The same holds where the other returns produce values whose construction does not read or write that local, such as return [] or return str("none") beside return x. Such a value is an exit's value as described under Exits below: it is built behind the local and moved down over it. When different locals are returned on different paths, only one of them can be placed at the destination, and the others are copied on return. A returned local that another return's value may read or write is copied as well. The compiler warns where a return keeps a returned local out of its destination in this way. This is in addition to §4.3's layout relocation.

Exits. A return or break may be taken while its destination already holds part of a value: for example inside an element of a literal being built there, or, for a return from (§7.9), below a named result built in the destination the target handed down. Such an exit abandons that part. The exit's own value is constructed behind the abandoned part and moved down over it; only such an exit needs this copy. The defer bodies of the blocks an exit leaves (§6.6) run after the exit's value is constructed and before the blocks' storage is released.

Destination requests. The construct-here information handed to a callee (or any constructing expression) comes in one of two forms, chosen by the receiver:

The request kind is part of the specialization signature (§10.2); the same function may be compiled in both forms for different callers. A callee that merely constructs its result passes the destination through. A callee that must first operate on its result as a whole array may make one copy into the requested element run, just before returning. Where its packed value can occupy the receiving storage, removing the header is instead the layout relocation of §4.3 and may be performed by the callee or receiver. A local resizable can use metadata outside the element region (Appendix C.2), with its elements already in place.

As a result, returning a built-up value costs the same as out-parameter style, and the idiomatic way to build a variable element "inside" a container is a function call in argument or push position.

7.4 Growing through a reference

Passing &v where v is resizable lets the callee push or append to it. For each call site the compiler either specializes on the identity of the stack that v tops (§10.2), or, when one body serves multiple stacks, passes the stack as a hidden argument or fat reference (Appendix E). No surface syntax distinguishes these.

An in-scope "current pool" mechanism (allocation without naming the array, compile-time bound to whichever suitable array is in scope) is deferred (TODO).

7.5 Nested functions and free variables

Functions may be declared inside functions. A nested function may read and write the enclosing function's locals ("free variables"), subject to those variables' normal rules (writability §9.5, roots §9.2, shrink rules §5). For definite assignment (§4.4), a call assigns a free variable only if every way out of the nested function's body assigns it. The ways out are each return (including those of a function value's body and a callee's return … from, §7.6, §7.9) and the end of the body. Where any of them may have assigned the variable, a let declared without an initializer cannot be given a value after the call. A function value's body (§7.6) encloses what is written in it in the same way: a function declared there, or a function value written there, may also name the value's parameters and the body's locals.

Names in a nested function resolve where the function is declared, whatever surrounds a call of it. A free variable is the variable in scope at the declaration. It is not a variable of the same name that a scope around the call declares, or that is declared further on, and it is never a variable that one of the function's own type parameters hides (§11.1). The functions a nested function can call are those in scope at its declaration and every function declared in the blocks around its declaration, before or after it. So nested functions may call each other in either order, as the functions of a recursive descent parser do. Calling one of them before the enclosing code has reached its declaration is an error. The enclosing code can name a nested function from its declaration to the end of its scope, and may pass the name directly as a static function argument (§7.6).

Nested functions with the same name declared in one block overload each other as top-level functions do (§7.1), so case functions (§8.2) may be nested and use free variables. A name refers to the overload set of the innermost block that declares it. For the enclosing code, that set holds the members declared so far; for a nested function's body, it holds all of them, declared before or after the body. A later nested function with the same parameter types as an earlier one shadows it instead of overloading it, as a later local of the same name does. The enclosing code then calls the latest one declared so far, and a nested function's body calls the latest one declared at or before it, or else the first one after it. A set in an inner block entirely hides the set of the same name in an outer block, and the top-level one as well, as a namespace's set hides the global one (§11.1).

Implementation model: free variables become hidden reference parameters of the nested function. A nested function's value never outlives the function that declares it (function values do not escape, §7.6), and a call made where a variable the function names is out of scope is rejected, so every call can pass the free variables. A nested function is specialized by how a call finds its free variables, as it is by its parameters (§10.2): where each one points, what it holds, whether it is assigned, and, for a let, whether it may already have been assigned. A function that calls the nested function is specialized in the same way, and so is a function that calls a function value naming such a variable. A call that shares a specialization with an earlier call leaves the free variables as the earlier call's check did: rebound, assigned, and holding what the body stored. When a nested function is passed as a static function value and inlined into its HOF, which is the expected, common case, the hidden parameters disappear; un-inlined (debug) builds keep them as real arguments.

7.6 Static function values

fn foo<F: fn(i64)>(a: i64) { F(a); }
foo(1) { print(it) }                  // trailing-block sugar, implicit `it`
foo(1) { x => print(x) }              // named block params
fn h(v: i64) { print(v) }  foo(1, h)  // or pass a named function

Function values are compile-time entities passed as generic parameters, not runtime data. The : fn(...) bound is optional documentation, and a bare <F> works the same: the call F(a) is typechecked per instantiation like everything else (passing a non-function produces the error at that call, reported with the instantiation chain). Wherever F is in scope it names the function value, hiding a variable of that name around a nested function's declaration, and a global or function of that name (§11.1). It is called as F(a); a member call a.F() also names it, and is an error. A block's value is checked without the destination type that its call's value goes to, so it cannot be a [] (nor a construct whose branches are all []), which would have no element type (§4.2).

Its receiving storage is supplied independently of that type inference: relative links in a fresh result are checked against where it will be constructed, including when the block is called from another function.

A fresh block result follows the same construction rules as a named function's result, including literals containing self-relative references. Selecting a fresh result through if, match, loop, or block preserves that property. A tail that instead names existing storage (or a reference to it) is still a copy and must obey §3.9's relative-reference restrictions.

A function argument must be a function name (including a bound generic function parameter) or a block literal. A name with several overloads passes all of them, and each call of the value resolves among them as a call by that name would. So each(xs, sort) with fn each<F>(xs: i64[:]) { F(xs); } calls std's one-argument sort<T>, and one value may reach different overloads at different calls. Runtime expressions producing a function value, such as a call, if, match, block, or loop, are rejected. Evaluate runtime work in ordinary statements before the call; use locals when its ordering with other arguments matters. A named function passed as a value keeps its parameter defaults (§7.1): F(x) may leave out the arguments it declares defaults for. A block's parameters have no defaults. A function value passed as an argument may come after fewer arguments than the callee has parameters, when the remaining parameters have defaults: each_step(5, show) for fn each_step<F>(n: i64, step: i64 = 1).

Function values capture enclosing locals per §7.5, and cannot escape: storing them, returning them, or putting them in data is a compile error. Every call is direct and inlinable; HOFs compile to hardcoded loops (Lobster-style guarantee). There are no closures-as-objects and no runtime function pointers in v1.

A plain return inside a function value returns from the lexically enclosing named function, not from the HOF that calls the value, so a HOF-based iterator has the same structuring power as for. This is implemented with the return from mechanism (§7.9), which unwinds the HOF's frames transparently.

7.7 Generics and monomorphization

Generics use monomorphic specialization, C++/Lobster-style. Type parameters are unconstrained, and the body is re-typechecked per unique combination of (type arguments, reference roots (§9.2), writability provenances (§9.5), static function values, destination stacks). Errors are reported at the offending instantiation with the compile-time call chain: because the compiler sees the whole program and checks it in call-graph order, it can always show which call path produced the failing instantiation. Every instantiation the program reaches is checked, so there must be finitely many: a recursive call may not keep making new ones (§7.8), nor may a generic type's fields (§3.2).

Type variables are never checked abstractly. A generic body is checked only at an instantiation where every type is concrete. That includes the result of every call the body makes on a function-value parameter, since that value's body is checked inline against the concrete argument types at that point (§7.6). So a HOF never needs to state what its function value returns: map's result element type is the type of F(x) in that instantiation, even when the block is { generic(it) } and that type depends on the instantiation.

Call-site type arguments. Type arguments are inferred from the argument types whenever they appear in the parameter list: fn foo<T>(x: T) can be called as foo(1) without an explicit type argument. The typechecker must support inference for both <T>-style and untyped (implicitly generic) parameters. Where several arguments mention one type variable, the typed arguments bind it and a literal then adapts (max(n, 0) with n: u32 is the u32 instantiation). So an i64 literal never fixes the type by coming first, and neither does an array literal of constants (§4.2). Among literals, a float literal binds before an integer literal, which converts to the float type (§6.3): max(1, 0.5) is the f64 instantiation. A float expression of literals and integers (n * 0.5) binds as a float literal does. A [] or null binds nothing. It takes the type that the other arguments, literals included, give its parameter, wherever it stands among them: both([], xs) for fn both<T>(x: T[], y: T[]) is the same instantiation as both(xs, []). An explicit list f<i64>(x) is allowed. It is needed when no argument mentions the parameter (e.g. qget<i64>(), or zero<f64>() for fn zero<T>() -> T), or when only a [] or null does (g<i64>([]) for fn g<T>(xs: T[])). It binds the leading type parameters in order, and the rest are inferred. Syntactically, f< commits to a type argument list only when the <…> is immediately followed by (, by { for a struct literal (Pair<i64> { … }), or by .ident for a variant literal (Opt<i64>.Some { … }) or a payload-less variant (Opt<i64>.None), and x.f< only when it is followed by ( (x.f<i64>(y), UFCS); otherwise < is the comparison operator. No operand starts with ., so a < b > .c is never a comparison.

Floating computation context. After ordinary argument inference and overload resolution, an f32 destination can refine a call's inferred f64 type parameter to f32. This applies when the declaration returns that bare type parameter (-> T) and every parameter mentioning it is a written, bare value parameter (x: T). Its arguments must be adaptable float literals or expressions (§6.3), adaptable integer constants, or directly nested calls satisfying this same rule. Matching those arguments as f32 must select the same overload with all other bindings unchanged; ambiguity or a different selected overload prevents the refinement. Normal resolution must succeed first: result context never rescues a failed or ambiguous call.

With integer width and height, the destination below therefore chooses f32 for both calls and their argument arithmetic:

let scale: f32 = max(0.5, min(width / 960.0, height / 600.0));

An explicit type argument for T, an already typed numeric argument, a reference or container parameter mentioning T, or a default supplying such a parameter commits its type. The rule does not infer a type parameter from the result alone, change an integer computation into floating arithmetic, or extend through an intervening operator or stored call result. Overload selection still sees the call's ordinary inferred type. Without an f32 destination, calls continue to default to f64; an inferred binding such as let saved = min(width / 960.0, height / 600.0); stores an f64 result.

This is not a general change to mixed-argument unification. With var count: i32 = 1;, let value: f32 = min(max(0.25, 0.5), count); still errs: count is a typed integer argument, so result context does not refine that call. Reversing the arguments does not make it valid. Write min<f32>(max<f32>(0.25, 0.5), count) to choose floating arithmetic explicitly for that mixture. In contrast, count * 0.5 is an adaptable floating expression and can participate in the contextual rule above.

An explicit inner min<f32>(...) also chooses f32. Alternatively, max(0.5, min(width / 960.0, height / 600.0)) as f32 computes in f64 and then rounds the result once: a cast does not pass its target type into its operand. These are different choices: min<f32>(n * 0.1, 1.0) with integer n = 9 computes 0.90000004, while min(n * 0.1, 1.0) as f32 computes 0.9. A typed intermediate is also valid, but is not needed just to choose a generic call's type.

Literal arguments. Where a literal is the only thing binding a type variable (the parameter is untyped, or its type is a bare T that no typed argument mentions), the variable takes the literal's default type (§3.1). Inside the specialization the parameter is then a literal parameter: a constant of unknown value that adapts wherever the bare parameter meets a type, as the literal would have. So push_n(flags, 1, n) with fn push_n<A, T>(xs: A&, v: T, n: i64) pushes a u8 into a u8[>..], and fill(small, -7, 2) pushes an i16 into an i16[..4], with no suffix or cast at the call. The value is not part of the specialization: every call with a literal shares one body, which is checked once with the value unknown. The body records each type the parameter adapted to: in the body itself, in a callee it passed the parameter on to as a literal, and in a block it handed the parameter to. Once the whole program is checked and the records are complete, each call's literal is checked against every recorded type: push_n(flags, 300, n) is an error naming the push that takes a u8. What the body cannot do with an unknown value, it cannot do with a literal parameter either: [0; n] needs a constant, and v + 1 is an ordinary i64 expression, just as let y = v; commits y to i64, so a recursive call on v + 1 passes a typed value and terminates. var parameters and extern functions take literals as ordinary values. Only a scalar literal makes a literal parameter: an array literal that is the only thing binding a type variable gives it its default type ([7, 8] an i64[2]), so a signature that ties the types (v: T beside xs: T[>..]&) lets it adapt instead.

Passing arrays by reference is the generic way to write mutating range algorithms (each array-family type instantiates its own copy); slices are the uniform non-mutating way.

7.8 Recursion

Non-recursive call graphs are the default and have no special requirements. Recursion is opt-in and annotated:

A call into the cycle is any call that can lead back to the caller: the back edge, and every other call reaching a member of the cycle. A nonfixed local is a variable- or resizable-class let or var, or a match arm's by-value copy of such a payload. It holds a data stack (§10.3) from its declaration to the end of its block. Its initializer builds it in place (§4.3), so it also counts at a call in its own initializer. If a nonfixed local were in scope at a call into the cycle, every activation would hold a stack of its own. So a scratch buffer declared in a block or a loop body that ends before the recursive call adds no stacks to the recursion: its stack is free again before any deeper activation starts. A scratch buffer declared before a loop whose body recurses is still in scope at that call, and is an error there, reported with the local. A by-value nonfixed parameter is in scope for the whole body, so no function in a cycle can have one: it takes a reference or a slice instead. A function value's body is part of the function it is written in, so the locals the body declares count where it calls into the cycle, as do the locals of the function calling it. A nested function in the cycle is a member like any other, while the locals of an enclosing function outside the cycle are owned outside the cycle (see below). Growable data that must outlive a recursive call is owned outside the cycle and passed in (references, slices, reusable pools, or one struct of references to several tables, A.6). The compiler checks every call into a cycle in call-graph order; recursion depth then only consumes native call stack.

Temporaries. The same holds for the unnamed nonfixed values of the statement that makes the call: at a call into the cycle, no nonfixed value of the enclosing statement may be live. Each such value lives on a data stack of the activation (§10.3), a temporary until its statement ends (§9.2) and a value under construction until it is complete, so it would otherwise need a stack per activation, as a local in scope does. That forbids:

The compiler reports each case with the call and the value. The alternatives are to evaluate the value in a statement of its own before the call (let width = str(n).len;, where the binding is fixed-size), to pass scratch in from outside the cycle (the mark / resize(mark) pattern below), or to take an output buffer by reference and build into it in place instead of returning a growable value. Fixed-size values and temporaries are never affected: a slice of a fixed literal, a struct of fixed fields and a large fixed value (§10.3) all use no data stack at the call. So a program's stack count is a compile-time constant, and the number of worker threads (§11.2) is the only dynamic factor.

Scratch passed down this way can be viewed across the recursive calls. An activation that takes let mark = scratch.len of a grow-shrink scratch array, pushes onto it, views its own part and ends with scratch.resize(mark) leaves the array as long as it found it, so its calls back into the cycle are balanced (§5.2) and the view stays in use across them:

recursive fn walk(scratch: i64[>..<]&, depth: i64) -> i64 {
    let mark = scratch.len;
    scratch.push(depth);
    let mine = scratch[mark..];
    var total = 0;
    if depth < 3 { total = walk(scratch, depth + 1); }
    total += mine[0];
    scratch.resize(mark);
    return total;
}

Polymorphic recursion. A recursive call may instantiate its callee with different type arguments from those of the call it is in (§7.7): flip<A, B> calling flip<B, A> is back at flip<A, B> one round later, and that call is the cycle's back edge. The instantiations a recursion reaches must still be finite. Every branch of every specialization is checked, whatever values reach it at run time, so a call whose types grow with each round (g(a, n - 1) with let a: T[1] = [x] inside g<T>, or grow<T[2]>(n - 1) inside grow<T>) would instantiate without end, even where n bounds the recursion at run time. The compiler rejects such a call once it would put more than 16 specializations of one function in progress on one compile-time call path, similar to how C++ bounds template instantiation depth, and reports it with the chain of instantiations that led there. A finite recursion has one specialization in progress per instantiation on its cycle, or per level of a nested type it descends through, so only an unusually deep recursion reaches the limit.

Cycle store rule. Within a recursive cycle, distinct activations of the same local are statically indistinguishable, so the §9.2 depth check is not sufficient there. Therefore a reference whose root is a local of a cycle function may be passed down as an argument, but may not be stored into any location, nor returned. A function is in the cycle for its whole body, not just from its call into the cycle on: the stores it makes before that call are inside the cycle too. Rebinding one of the activation's own reference variables (cur .= cur.next) is not such a store: the variable dies with the activation, and what it is bound to came from an activation that outlives it. References rooted outside the cycle may be stored. In particular, this covers a pool handed to the cycle by reference: a parameter whose pointee is resizable-class (no cycle function can hand such a value on to the cycle, since it holds none across a call into the cycle). So a recursive builder can push into a caller's local pool and link what it pushed. A local of an enclosing function outside the cycle (a free variable, §7.5, of a nested recursive fn) also outlives every activation. So a parser keeps its pool, its key table and its input as locals of a non-recursive parse, and the nested recursive functions freely store, link and return references rooted at them. Because the cycle's functions are checked against the entry call's roots, every recursive call must pass such a pool by the same reference the entry call did: swapping two pools, or passing a different one, at a back edge is a compile error. For the same reason a call into the cycle is checked against the stores its callee's body makes: a back edge that gives a parameter storage outliving what the body stores through it (a global, where the entry call gave a node of the caller's pool) is rejected as a store there would be (§9.2).

A reference or slice parameter that the cycle passes on is rooted outside the cycle in the same way, whatever the size of its pointee. Suppose that:

The parameter then points at the same storage in every activation. References rooted at it or read through it may be stored inside the cycle, provided the destination storage is also the same in every activation. This can be a local, a pool, or storage pointed to or reached through another parameter with the same guarantee. It cannot be storage pointed to or reached through a parameter that the cycle passes a different value. For example, a recursive walk over a context of borrowed tables (A.6) can store views read through the context into tables reached through that context.

A parameter can also point outside the cycle in every activation, though not at the same storage in each. That holds when the call that first reached the function passed it a reference or slice rooted outside every recursive cycle, or read out of storage there, or read through such a parameter or a pool. Every call back into the cycle must then pass it one of these:

Each such argument must also outlive what the first call passed, since it may be stored wherever that argument may be (§9.2). What is rooted at such a parameter, or read through it, may then be stored in the same way, but the parameter is not itself storage to store into. For example, a builder hands the node read through the context to the function that links it under a new node (rest(c, c.nodes.push(…))). A tree walk gives the node the entry call passed, and then each node it collected into a caller's array:

struct Node { first: Node&?, next: Node&?, n: i64 }

fn collect(root: Node&) -> i64 {
    var out: Node&[>..<] = [];
    recursive fn walk(node: Node&, depth: i64) {
        var k = node.first;
        while k != null { out.push(k); k .= k.next; }
        if depth > 0 {
            let end = out.len;
            for i in 0..end { walk(out[i], depth - 1); }
        }
    }
    walk(root, 2);
    return out.len;
}

A parameter of a type that holds references by value, such as an enum whose payload holds one, is judged by where those references point, in the same way. Nothing requires a recursive call to pass such a parameter on, so the parameter is restricted only once a store relies on it. A later call into the cycle that passes it anything else is a compile error reported at the store, and such a call before any store makes the store the error.

Cycle return roots. A back edge reaches a function whose own returns may not have been checked yet, so the roots of its result cannot be taken from those returns right away. Instead, a cycle is checked in rounds:

A cycle function may therefore return the result of a back-edge call, and a parenthesised subexpression in a recursive-descent parser needs no wrapper node (bench/goose/calc_noparen.goose). A cycle function that returns either of two pools it is handed can store the result where both pools outlive the store. A cycle function that returns a view of a grow-shrink array on any path has a result that no store may keep (§5.2). A result that holds references, such as a struct with slice fields, is checked the same way, by the roots of what it holds (§9.2). So a recursive builder may return views into an arena that outlives the cycle, built from its children's views, with the recursive call either before or after the base case's return:

struct Fragment { text: const u8[:] }
var arena: u8[>..] = [];

recursive fn nest(n: i64) -> Fragment {
    if n == 0 { arena.append("x"); return Fragment { text: arena[..] }; }
    let child = nest(n - 1);
    return Fragment { text: child.text };
}

7.9 return … from (long-distance return)

return E from f     // f = a named function on every compile-time call path
                    //     from f to this statement

f is resolved where the returning function is written: it names a nested function in scope, or else the name's overload set by the rules of §11.1 (from image::parse is explicit). The target is the innermost enclosing call of a function in that set. An unrelated function that merely shares the leaf name never catches the return.

return E from f returns E as the result of the innermost active call of f, unwinding all frames in between. It is the language's opt-in, lightweight "exception" mechanism (e.g. a deep parser/loader error doing return err from load_level), and the mechanism behind function-value return (§7.6).

Semantics and implementation:

Error handling has no required error-value convention: an application may use a bool, enum, string, i64, or another type. By convention, the error is the last of multiple return values. Use ordinary multiple returns for local propagation and return from to return across several calls.


7.10 extern fn

extern fn sqrt(x: f64) -> f64;
extern fn sqrt(x: f32) -> f32 = "sqrtf";
extern fn read_file(path: u8[:], out: u8[>..]&) -> bool = "gs_os_read_file";
extern fn gl_vertex3(v: float3&);

A declaration without a body binds a Goose signature to a C function. A string after =, where a body would go, is the C symbol (by default, the symbol is the Goose name). The older syntax with the string after extern, extern "sqrtf" fn sqrt(x: f32) -> f32;, is deprecated. It binds the same way and draws a warning naming the = "sqrtf" form (except in a package fetched from a git source, since it is up to that package's authors to change it). A declaration giving both strings is an error. An extern fn is top-level only, fully typed, not generic, and joins overload resolution like any function. A call compiles to a direct C call with no calling-convention extras, and a prototype is emitted from the declaration unless the runtime declares the symbol (the os library's gs_os_* functions, declared in src/runtime/runtime_ext.h and defined in src/runtime/runtime_os.h). User C code is added to the program with --include <header>, which is emitted after the generated type declarations so that a header can implement shims against the generated typedefs. The types in every extern fn signature are emitted even when the function is unused, including the complete types behind reference and slice parameters. An unused extern does not require a C implementation to link.

Every name that comes from the program appears in the C with a _g suffix: a struct Stats with a field lo becomes Stats_g with lo_g. This keeps the generated file clear of the C keywords and of whatever the platform's headers declare, without depending on a list of names to avoid. A namespaced declaration ns::x (§11.1) becomes ns_x_g followed by the namespace's length (image::Pixel is image_Pixel_g5), so a::b_c and a_b::c stay distinct and no namespaced name coincides with a global one. The exception is an extern fn's own symbol, which is the C name the declaration gives, unchanged.

An extern fn's trailing parameters may have defaults (§7.1). Each call passes them, so the C function always receives every argument.

These values can cross the boundary (with layouts per C.2):

A return is one scalar or flat fixed-size value by value, or nothing; references, slices and builders can only be parameters. Everything else (varints, references inside structs, variable and resizable values by value, slices of variable elements, optionals) is rejected at the declaration. A parameter of reference or slice type is as writable as its type says (§9.5): passing a read-only argument requires the parameter to be declared const.

C must preserve the types and lifetimes of borrowed storage, must not retain a borrowed argument beyond the call, and may only append to a builder. The compiler assumes an extern may write through its arguments but does not mutate unrelated Goose globals; C code is responsible for honoring those obligations.

Binding from a shared library. An extern block names a shared library and declares functions that are bound from it by name when the program starts, without any C code of the program's own and without linking:

extern "SDL2" {
    fn SDL_Init(flags: u32) -> i32;
    fn SDL_Quit();
    fn SDL_GetTicks64() -> u64;
    fn delay(ms: u32) = "SDL_Delay";
}

A member is an extern fn in every respect above. It is fully typed, non-generic, and has no body. It accepts the same shapes, with one exception: a struct holding a pad by value must cross by reference. The library's struct has padding where Goose's generated C has a byte array, and the calling convention may pass these differently. A string after = gives its C symbol, which is otherwise its Goose leaf name. Its name is a declname, so a namespaced member is written as it would be outside the block. A member is written without extern, since the block's extern covers it. export, thread_fn and the qualifiers an extern fn cannot carry are errors inside the block, with the same messages as on an extern fn. A block is top-level only, may not nest, and may be empty. Two declarations may bind one symbol under different Goose signatures, each through a pointer of its own. A member that no call reaches is never looked up, and a library none of whose members is called is never opened.

The string is a bare name or a file name, interpreted for the platform the program runs on:

string Windows macOS Linux
"SDL2" (no separator, no dot) SDL2.dll libSDL2.dylib libSDL2.so
"libfoo.so.3", "foo.dll" (a dot) as written as written as written
"bin/steam_api64", "./x.so" (a separator) relative to the program's resource directory (resource_dir(), docs/stdlib.md) unless absolute; the bare-name rule applies to the last component

A bare name is passed to the platform's loader as a bare name, and the loader searches for it in its usual way: the DLL search order on Windows, DYLD_LIBRARY_PATH and the usual places on macOS, and LD_LIBRARY_PATH, the rpath and the cache on Linux. A dotted file name is taken literally and is therefore specific to one platform; it belongs in the sidecar rather than the source, unless the program targets only one platform. The sidecar's dylibs list (which takes the _windows, _linux and _macos suffixes, as every list does) maps a name that a block uses to the spelling to open, as name=spelling. A package's sidecar maps its own names as defaults, and the program's sidecar can map a name for any package it uses. For a platform that the program's entry says anything about, the program's entry takes precedence. A name that no block declares is an error at the sidecar (docs/packages.md).

The libraries are opened and the symbols resolved on the main thread when the program starts, after the runtime starts and before the globals' initializers run. The pointers are written once and only read after that. A library that cannot be opened, or a symbol it lacks, is a runtime error naming the spelling tried, and the program does not start:

cannot load SDL2 (libSDL2.so): libSDL2.so: cannot open shared object file: No such file or directory
SDL2 (libSDL2.so) has no symbol SDL_Foo

A library is never closed. A package's main_thread_only restriction (docs/packages.md) applies to the members of its blocks as it does to its other externs. A variadic C function cannot be bound: the declaration has no way to say so, and calling one through a fixed-arity pointer is undefined. The generated C is the same on every platform, since the runtime chooses the spelling, and a program whose only C is such libraries runs under the in-process backend with no C toolchain installed. Conventions for declaring a real library's functions under these shapes are in docs/reference.md §24.

7.11 Exporting Goose functions to C

export fn hello_world() -> const u8[:] {
    return "Hello, world!"
}

export "c_sum" fn sum(a: i32, b: i32) -> i32 { a + b }

An export fn defines a normal Goose function and emits a C-callable wrapper. The optional string names its C symbol; otherwise the function's leaf name is used. Exported functions must be top-level, non-generic, have explicit parameter types, and have at most one explicitly typed return value. Default arguments, return ... from, reusable-pool parameters, and string-builder parameters are not supported at the C boundary. Parameters otherwise use the same fixed-size C shapes as §7.10.

Scalars and flat fixed-size returns are returned by value. A read-only const u8[:] return is exposed as const uint8_t * and an additional int64_t *out_len parameter; the returned bytes are not NUL-terminated, and their storage remains borrowed according to the Goose function's lifetime. Other references, slices, and variable-size returns are rejected.

Generated C retains its normal main unless the program is compiled with --header out.h, which builds it for a C host: the generated C has no main and a source without a Goose fn main() is accepted. The header declares goose_init(argc, argv), which a C host must call before any exported function. It initializes the runtime and the globals and then runs the Goose fn main(), if there is one, so main is where a library keeps its own start-up code; exported functions may be called from it. A second call does nothing. Initialization is idempotent on the initializing thread; exported calls and mutable Goose globals are not thread-safe and must remain on that thread. --header requires generated C output and at least one export, and writes declarations and the required packed C type definitions for the exported API.

The header also declares goose_exit(), which ends the program: it releases what goose_init set up (the data stack regions, the queues, and the runtime's handling of faults, which returns to the host's), after which goose_init may start a program in the process again, this one or another build of it, as a host that reloads a library does. It must be called from the initializing thread, with no thread program of the program still running, which is a runtime error; exported functions may not be called again until goose_init has. Before goose_init, it does nothing. What the Goose main set up outside the runtime is not undone. Where programs share a runtime object, starting one while another is started is a runtime error, rather than the first's regions being forgotten.

An exported C symbol is emitted as written, as an extern one is, and must be a C identifier that is not a C keyword and that the program does not otherwise reserve (main, goose_init, goose_exit, or a name beginning with gs_).

The wrapper calls the normal generated Goose specialization, not its hidden internal C signature. This keeps Goose's stack argument and other compiler implementation details out of the function's public C signature.

7.12 simd fn: versions per instruction set

simd fn encode(out: u8[>..]&, src: u8[:]) { ... }

simd asks the implementation to build the function once for the baseline instruction set of its target and once more for each wider one it supports (AVX2 and AVX-512 on x86-64), and, every time the function is called, to run the widest version that the CPU running the program supports. It is meant for a function containing a hot loop that the C compiler can vectorize, but only poorly or not at all with the baseline's instruction set, such as byte transforms, codecs and kernels over arrays.

simd has no meaning of its own: every version computes exactly what the baseline does, so a program's output, aborts and exit status never depend on which version runs, or on whether there are versions at all. Integers already compute at their exact widths (§6.2), checks abort where they would, and float operations round as the baseline's do, one rounding per operation: no version fuses a multiply and an add into one rounding where the baseline would not. An implementation may ignore simd entirely, as the C backend does under compilers other than clang on x86-64 and under the JIT.

The one exception is the standard library's mul_add(x, y, z) (math), which asks for x * y + z rounded once where the hardware can do so and accepts it rounded twice where it cannot. Which one a call gets is up to the implementation. It may differ between targets, between builds for one target, and between a function's versions. Because the product is not rounded on its own, the two can differ by much more than the last bit: where x * y and z nearly cancel, including a zero against a nonzero, and where x * y overflows but the sum does not. The C backend rounds once on arm64, in x86-64 code built for AVX2 or later, and in a simd function's AVX2 and AVX-512 versions. It rounds twice everywhere else, under MSVC and under the JIT. A * followed by a + is never fused, mul_add or not.

Versions are per function: each version contains everything the function contains, including the callees inlined into it. The function itself stays out of line, since a caller it was inlined into would run the baseline's code. Each call to it performs one test of the CPU's level, so simd suits a function that runs a loop rather than a function called from inside a loop. The cost is code size: the function's body appears once per version.

simd comes after export and before recursive. It applies to functions with bodies, including nested and generic ones (each specialization has its own versions), and extern simd fn is an error.

7.13 inline fn: inlined whatever its size

inline fn project(cam: Camera, p: double3) -> f64, f64, bool { ... }

inline asks for the function to be inlined at every call, however big it is and however many calls there are. Without it, the implementation decides from the function's size and its number of calls, and a function called from a hot loop can stay a call because it is called from several places.

inline has no meaning of its own: a program computes the same with or without it. It is a request the implementation may not be able to grant at every call. Where Goose cannot inline a function itself (one returning several values, §7.3), the C backend asks the C compiler to inline it instead. Where inlining is off (-O0), every call stays a call.

inline is a keyword only directly before fn, recursive or thread_fn, so it stays usable as a name. It is an error together with extern (no body to copy), export (its C wrapper calls it), simd (a caller would run the baseline only), recursive and thread_fn.

7.14 checkfree fn: no bounds check left

checkfree fn bfs(dist: i32[>..]&, q: NodeId[>..<]&, src: NodeId) -> i64 { ... }

checkfree asks the compiler to reject the program if a bounds check is left anywhere in the function's text after bounds-check elimination (§10.5): in each specialization of it, each simd version and each copy inlined into a caller. It changes nothing about what the program computes or how it is compiled. A check belongs to the text it is written in, so a caller's argument expression is the caller's, and a nested function or block written inside a checkfree function is checked as part of it.

8. ADTs in use

8.1 match

match shape {
    Circle c => c.r * c.r * 3.14159,   // payload by value (a copy)
    Rect r   => r.w * r.h,
    Point    => 0.0,
}
match sexp {
    Sym &s   => use(&s.name),          // payload by reference (&-binder)
    List &l  => walk(l.kids),
}
match n { 0 => "zero", 1..10 => "small", _ => "big" }
match t { GROUND => "ground", world::GATE => "gate", LO..HI => "mid", _ => "other" }
match d { 0, 6 => "weekend", 1..6 => "weekday", _ => "no day" }
match dir { North, South => "vertical", East, West => "horizontal" }

8.2 Case functions (match as an overload set)

The cases of a match may instead be written as separate functions, each taking one variant type in the same parameter position:

fn area(c: Shape.Circle) -> f64 { c.r * c.r * 3.14159 }
fn area(r: Shape.Rect) -> f64   { r.w * r.h }
fn area(p: Shape.Point) -> f64  { 0.0 }

let a = area(s);     // s: Shape — dispatches on the tag, like a match

This is the language's "virtual function" idiom, without vtables or inheritance, and it keeps the closed-world exhaustiveness guarantee.


9. References, lifetimes, and safety

9.1 The outlives rule

A reference/slice must not outlive the variable that owns its target, and must never observe its target at a wrong type. Everything below enforces this rule statically. The surface language has no lifetime annotations and no aliasing or exclusivity restrictions. Aliased references are allowed: without shared-memory concurrency, and given the layout rules of §3/§5, aliasing alone cannot break type safety. This outlives-rule is the only "borrow checker" the language has.

9.2 Roots and the depth check

Every reference/slice value has one or more static roots. Each root is a local or global variable, or a temporary (below), that bounds the scope the target lives in. A value that may point at several places has one root per place (merged values, below), and every rule is checked against each of them. A root is exact when that variable's own storage contains the target, and inexact when it only bounds the target's lifetime; the owner is then that variable or a variable further out. Every root that a &lvalue creates is exact; inexact roots come from the reads out of containers described in §9.5. Because functions are compiled in call-graph order and specialized per instantiation, roots are always statically known: a parameter's roots come from each call site, and a function is specialized per distinct root. This gives the precision of Rust lifetimes through monomorphization, with no syntax.

The rules below are the scope rules, and they apply to exact and inexact roots alike. Exactness matters only to the rules that need the target's identity rather than its lifetime:

Without an exact root, each of these takes its conservative answer. A relative reference that names a pool (§3.9) is another source of exact roots: a load out of one is rooted exactly at that pool, whatever container it was read out of.

Rules (scopes ordered by nesting; globals are the outermost scope, §11.1):

Violations are compile errors. There is no escape hatch in v1.

9.3 What the runtime still checks

The runtime aborts (prints a message and exits; an abort cannot be caught) on each of the following. In a contained worker (§11.2), each of them except the last two ends only that worker:

An abort's message ends with the source location of the check that failed. A check inside a standard library function (§11.1), or an abort there, reports the place in the program's own code that called the library instead: the call that, through any further library functions, led to it.

exit(code) ends the program normally with the given process exit code. Neither abort nor exit returns, and the checker takes this into account: code after them is unreachable, so a branch ending in one of them gives no value (§6.4).

9.4 Type-safe reuse and stale references

With reusable arrays and limited arrays, a stale reference can read a different value of the correct type (type-safe reuse). This can cause a logic error, but not memory corruption, type confusion, or out-of-bounds access. Slot reuse allows these errors while avoiding a general-purpose allocator.

External bytes must be validated before they become a Goose value. from_bytes (§12) checks an image's framing, tags, lengths, and self-relative links. Each link must point to an element start in the same image. Invalid input returns false and an empty array, so it cannot introduce an unchecked reference. This verifies safety, not data integrity: edited bytes may still describe a valid structure. Detecting changes to the original data requires a separate check, such as a checksum.

9.5 Writability

Everything is writable unless its type says otherwise. Any type may be qualified const (§2). A const i64[3] or const Point is a value whose contents cannot be written into (its elements, its fields, and whatever a reference to it reaches). const T& and const T[:] are a reference and a slice through which the pointee, or the elements, cannot be written: no assignment, compound assignment, ++, or growing/shrinking operation through them compiles. Whether a const value can be assigned as a whole is decided by its binding (let, §4.4), and a copy of a const value is a fresh, writable value: a cursor var s: const u8[:] = text; advances with s = s[1..] and never writes a byte of text. const is shallow: a reference or slice read out of a field of a const Box& is as writable as the field's own type says, and so is a path through it (b.r.n = 7, b.s[0] = 7). The exception is a self-relative reference (§3.9). It points within the value or array holding it, so it is no more writable than the path that reaches it. Every value of reference or slice type is either read-only or writable, and where that property comes from is inferred per instantiation, in the same way as roots, with no annotation needed:

Read-back roots. A container names a scope, not the storage its contents point into, so the root of a reference or slice of pointee type T read out of a container C is re-derived. A candidate is a variable whose own storage can hold a T by value: an array of T in any array kind, a struct or ADT payload with a T field, a T itself, and so on through by-value nesting. A variable that only holds references to T is not a candidate, and a reference or slice field ends the search. Static data is a candidate for the element types a literal can supply. For a writable reference or slice, static data is a candidate only when nothing else is, since a writable slot is never given a literal (above) and so holds static data only as a null or an empty slice. For a read out of a field, an element or a global, a variable holding a grow-shrink array whose elements can hold a T is not a candidate either: a reference rooted at that variable is never stored (§5.2), whatever else of the variable could hold the T. A reference to the whole array, or to what holds the array, is stored like any other: its pointee is not an element, and the variable stays a candidate for it. Then, depending on where C's own root lies:

  1. A global. Only globals outlive globals (§11.1), so the owner is a global candidate, whatever local scope is open. If there is one candidate, the root is that variable, exact. Otherwise the root is the global scope, inexact.

  2. A local of the function being checked, at any block depth, including by-value parameters. Everything stored into C was reachable from this frame and had to outlive C. So the owner is a local declared at or outside C's block, a reference parameter's pointee, a global, static data, or storage of the caller's that a parameter leads to through references. Those references are the ones a by-value parameter holds (which its argument filled), and the ones in what a reference parameter points at. This function cannot enumerate that caller storage, but all of it outlives the parameter's root (for a by-value parameter, the root of what its argument held: §9.2's generic roots of reference fields), so that root is its candidate. Every candidate is a root of the value: a local or a global as an exact root, and such a parameter's root as a bound on the storage behind the parameter. The value names one array only when there is exactly one candidate in all and that candidate is not such a parameter's root. The candidates are the variables in scope at the read: an array that comes into scope after the read cannot be the owner and is not a root of the value.

    Where C is a local that holds values rather than a reference or slice, everything stored into C since it was made is on record: stores through a reference to it, by a function it was passed to, or later in a loop around the read. Where each of those stores was rooted at one variable exactly, at static data, or at the storage a parameter's argument holds, the value's roots are the roots of those stores instead. A variable whose storage cannot hold the value's pointee type is not one of these roots, since it owns none of what was stored there of that type: an i64& read out of a holder that also views a u8 buffer is not rooted at the buffer. A word of words(buf) for a local buf points exactly into buf, whatever else in scope could hold a u8. A by-value parameter holds what its argument held, so a slice of g.edges for a g: Graph parameter is bounded by that argument's root. It is not bounded by the callee's other parameters, which can hold its element type but none of the caller's views.

    A copy of a field or an element of such a local (a let of one, a for or match binder's copy, a popped element, or a value loaded whole through a reference to it) holds no more than was stored into the local, and that record then applies to the copy too: out of let hs = [H { r: a }], hs[0].r points exactly into a. A copy out of any other container has only its container's scope on record, and its roots are the candidates.

    A nested function's or a function value's body reads a local of the function it is written in as one of its own locals here, with that local's stores on record in the same way: the body is checked for the state each call finds the local in (§7.5, §7.6). A for loop also reads its binder in this way in each of its checks, so that what the loop body stores into the holder it walks reaches the elements that later iterations read.

  3. A reference parameter's pointee, or itself inexact. The owner may be caller storage that this function cannot enumerate, so the root is C's root, inexact. A non-recursive function can retain the caller's more precise record when its argument names a local holder exactly, all the holder's recorded contents point into one root exactly, and only parameters can reach that holder during the call: neither the function nor a function value it runs can name it directly. Reads then have that contents root, including any roots stored through possibly aliased parameters while the body runs. A nested function or callback's stores keep the distinction that the resulting references came from that holder's contents; call sites check them against the actual stores into its storage, including aliased stores. Stores feed back around loops, and calls are specialized for the contents they receive; a call after a rebind is checked for the new contents. The same rule applies when a parameter forwards such a holder to another function. It does not separate fields whose contents have different roots: those retain the conservative bound. For example, a Cache& naming a local Cache { bytes: buffer } keeps buffer as the root of cache.bytes, so an unrelated reusable byte pool cannot be that buffer. Shrinking it still checks every live reference and stored view into buffer, including those in the caller.

  4. A temporary (§9.2). Everything in it came from the literal's initializers, from the call whose result it is, or from the value it is a copy of, so its root is theirs: the innermost root among the initializers, the root of the result's contents, or the root of the copied value's contents. The root is exact when it is one variable exactly.

Any of the program's globals may be a candidate. A function that a global initializer calls is compiled before the globals declared after that initializer, but that compilation also serves the calls made once those globals exist. So inside that function the global scope stands for those globals as well, as an inexact root.

A global var of reference or slice type is a slot that any function may bind (§9.2). The compilation of a function that uses it serves every call, including calls made after a function compiled later has bound it. So a use of such a global in a function's body is a read out of a global (case 1): its roots are each global whose storage can hold its pointee, exact where there is only one. They never include a grow-shrink array's elements, since no binding of a global points there (§5.2). Such a global that names a pool (§3.9) points into that pool, and a global let keeps its binding. A global initializer runs once, when the bindings made so far are all there are, so a use in it has the roots of those bindings.

An optional variable bound only to null so far (let none: Node? = null;, or var best: Node? = null; before anything binds it) holds null. Where every binding that can come before a use of it has been checked, the use is a null, which has no root (§3.9). For a local, that is the case wherever it is used. In the body of the function declaring it, this includes a loop's later bindings (§9.2). It also holds in a nested function's or a function value's body. Such a body is checked for how a call finds the variable (§7.5), so a call after a binding has a check of its own, and the body's own bindings reach a loop or a recursion around the call as the declaring body's bindings do. For a global, it is the case in the global initializers, and anywhere for a global let, which only its initializer binds. A global var used in a function's body is read as described above.

A relative reference T&<w> read out of C always points within C's own root array (§3.9), so it takes C's root and C's exactness, whichever case applies. A relative reference that names a pool needs no candidates: it points into that pool, so it is rooted there exactly. This keeps a container of links usable where a container of plain references would be ambiguous.

When a diagnostic depends on an inexact read-back, it names the source container and the possible roots. For example: "n was read out of slots and may point into pool or spare", or "g is a global var, which any function may bind, and may point into pool or spare". If only the caller knows the candidates, the diagnostic instead names the parameter whose pointee bounds the value.

Writability checks depend on the callee's operations. A function that mutates its slice argument can compile for a writable argument and fail for a read-only one. The compiler reports the full compile-time call chain, including the source of the read-only data. const is required on slots that must accept read-only references or slices; parameters and results may also use it to state that they are always read-only.


10. Compilation model

10.1 Whole program, call-graph order

Goose is a closed-world, whole-program compiler. Functions are typechecked and specialized in call-graph order (callers before callees), so argument types, reference roots, writability, destination stacks, and static function values are always concrete. Recursion is the annotated exception (§7.8).

10.2 Monomorphization

Each function is compiled once per unique combination of (argument types, reference roots, writability provenances, destination/target stacks and request kinds, static fn values), with a nested function's free variables counting as arguments (§7.5). Roots and stacks are compile-time constants inside each specialization, so pushes through references compile to direct bumps of a known global. The exception is where the compiler chooses a hidden stack argument to share one body across contexts (§4.3, §7.4).

10.3 Stack assignment (deterministic recipe)

Traverse specializations DFS from main (and global initializers). Each specialization carries a compile-time stack environment: the watermark of stack indices in use on entry, and a map from each of its nonfixed locals / by-value params / in-flight returns to an index. Assign each new simultaneously-live resizable (and each data-stack-resident variable value, which shares by nesting per §1.3(4)) the lowest index not in use at that point. Indices become free again when the owning scope exits (sequential reuse), so a function called after a local's block has ended may use that local's index again. This is what lets a recursive cycle's functions own scratch (§7.8). A pending nonfixed return's destination stack is part of the in-use set from the call site until the value is received. This is also what makes return from safe (§7.9). N = the maximum index + 1 reached anywhere. Threads run the same algorithm per thread program (§11.2).

Implementations may replace per-context specialization with hidden runtime stack arguments where profitable; semantics are identical.

Fixed locals normally go to the native stack / registers. The C backend places fixed locals and temporaries larger than 4096 bytes on data stacks, using the same scope watermarks and hidden stack indices as nonfixed values. This threshold is a backend policy, not a language limit or a change to a type's size class, packed layout, value semantics or reference lifetime. It prevents large arrays and records, including temporaries introduced by inlining, from exhausting a platform's much smaller native call stack. Inside a function of a recursive cycle (§7.8) such values stay on the native stack instead. A data stack held across a call into the cycle would need one stack per activation, which §7.8 rules out for every value. The recursion's depth is already bounded by the native stack, and the native stack is where fixed values normally go. So a large fixed parameter copy, local or temporary of a cycle function is a native value; deep recursion over large values avoids this by taking them by reference. Rejecting such parameters and locals in cycle functions instead would have made the backend's threshold a language rule. Variable-class locals may be placed on the native stack (alloca) instead of a data stack when the compiler chooses; the current backend places them on data stacks. Resizable frame objects (C.2) still keep their fixed prefix and tail header in the native frame.

10.4 Runtime environment

At startup, reserve N address regions (target: multiple GB each; commit-on- touch via guard pages, prototyped at github.com/aardappel/stackalloc), plus guard gaps between regions so runaway growth aborts cleanly. An implementation sets one budget for the address space that regions use across all thread programs together (32 TB by default here), and this budget bounds hardware_threads() (§11.2). A region that the platform refuses may be reserved smaller; nothing that the implementation elides depends on a region's reserved size. Platforms without address-space reservation (wasm today) fall back to index-based references and bounds-checked growth, with reduced performance.

The 48-bit size limit. A data stack reserves at most 2^48 bytes. Consequently, in every conforming implementation:

This limit allows up to 256 TB in one value while leaving headroom for arithmetic the compiler can reason about. The guard region after each reservation already turns an attempt to exceed it into a safe abort (§9.3), so no growth operation needs a separate check for this limit. A fixed array's size is a constant checked at compile time. Implementations may impose a smaller limit (wasm32 is inherently capped at 2^32); they may not raise it, so a program's meaning never depends on the target having more.

Making the limit part of the specification provides three guarantees:

Compilation target: C/C++ first, LLVM later. Representation and calling convention: Appendix C.

10.5 Bounds-check elimination

The index and slice checks of §9.3 are the only abort that the compiler routinely proves unnecessary, so the analysis that removes them is part of the compilation model rather than an implementation detail. It runs over each specialization after optimization, and it is required only to be sound: every check it removes is one that could not have aborted, and a check it fails to prove unnecessary stays in the program. Nothing about a program's meaning depends on how much it proves, except whether a checkfree function compiles (§7.14), for which the specification fixes what every implementation must prove.

Beyond ordinary flow facts (loop headers, conditions and their negations, assert, match arms), the language gives the analysis the following:

Known gaps are listed in TODO 0f. Whole-program compilation lets the analysis reach across calls: each specialization has concrete argument roots, a caller's facts reach an inlined callee body directly, and a call that is not inlined carries them too. Every call site records what it proves about the arguments it passes (constant bounds on an array's length, and how the passed lengths and integers relate). A specialization enters with the meet of those facts once all of its call sites have been analyzed. Callers are analyzed first, and a call site inside a recursive cycle gives its callee no facts. A thread's entry point and an export fn, which C may call with any arguments, enter with no facts. In the other direction, a callee is summarized rather than assumed hostile. The storage it may resize or overwrite (through its reference parameters, in globals, or in captured outer locals) is computed as a fixpoint over the call graph, and a call kills only the facts that those effects can name. So a kernel fn blur(src: u8[>..]&, dst: u8[>..]&) that only reads and writes elements can use src.len == W * W when every caller established it, and a call to it leaves the caller's own facts about src intact.


11. Program structure

11.1 Modules and globals

11.2 Concurrency

Threads never share mutable memory. The model is a statically typed relative of the Linda tuple-space / coordination style.

11.3 Dynamic stacks (future)

This future feature is a resizable-of-resizables local in which each element owns its own dynamically created stack and is accessed through fat references. It is not in v1. (The thread mechanism of §11.2 already introduces the dynamic-stack-block machinery this needs.)


12. Builtins, the standard library, and what v1 leaves out

The language's own functions:

Builtin Meaning
print(a, b, …) the arguments' text and a newline to standard output (§3.7)
str(a, b, …) -> u8[>..] the arguments' text as a fresh string, built at its destination (§3.7)
format(out, a, b, …) the arguments' text appended to out, any growable u8 array (§3.7)
assert(c) aborts unless c, a bool or an optional, which it narrows (§9.3, §3.8)
abort(msg: u8[:]), exit(code: i64) end the program; both diverge (§9.3)
copy(x) -> T a fresh copy of the stored value x names (§4.1)
default<T>() -> T the value a fixed-size T has before anything is written to it (§4.2)
hardware_threads() -> i64, thread_spawn(worker, args…) -> i64, thread_spawn_contained(worker, args…) -> i64, thread_wait(id) -> bool, thread_wait(id, msg) -> bool workers (§11.2)
qput(v) -> bool, qget<T>() -> T, qget<T>(ms) -> T, bool, qpoll<T>() -> T, bool the typed queues (§11.2)
qlimit<T>(n) a queue's limit of messages (§11.2)
to_bytes(a) -> u8[>..], to_bytes(a, out) an array's image (a varint byte count, then its element region), either fresh or appended to a growable u8 array (§3.9, design/serialization.md)
bytes_of(a) -> u8[:] the element region alone, as a view: no copy, and never writable
from_bytes<T[>..]>(b: u8[:]) -> T[>..], bool a verified array from an untrusted image; empty and false if it is not one. Also builds T[>..<] and the T[] family
embed_shader(path) or embed_shader(stage, source, …) -> const u8[:] compile-time GLSL compilation to a static shader blob; graphics extension, detailed in the implementation notes, section 3.14
embed_file(path) -> const u8[:] the bytes of a file, read when the program is compiled, as a view of static data; path is a string literal (or a let/const global holding one), relative to the file with the call; each distinct file is read once
src_file() -> const u8[:] the base name of the file that contains the call, a view of static data. In a default, the call that left the argument out (docs/design/call_location.md)
src_line() -> i64 the 1-based line of that same call

And the array members, ordinary functions of their receiver per UFCS (a.push(v) is push(a, v)):

Member On Meaning
.len -> i64 every array kind and slices the element count (§3.3)
.cap -> i64 limited arrays the capacity (§3.3)
.push(v) -> T& limited, grow-only, grow-shrink one element, constructed in place (§3.3)
.append(src) limited, grow-only, grow-shrink an array or slice of elements (§3.3)
.pop() -> T, .pop_n(n), .resize(n, v?), .clear() limited, grow-shrink; grow-only where §5.1 allows a shrink shrinking, and growing with a fill value (§3.3, §5)
.index_of(r) -> i64 fixed, limited, grow-only, grow-shrink the index of the element r refers to (§3.3)
.range_index(s, msg?) -> i64 the same, fixed-size elements checked start of a contained slice; zero for empty (§3.3)
.check_length(n, msg?) -> i64 grow-only, grow-shrink, fixed-size elements validate a count without allocating (§3.3)
.move_from(src) slices, fixed-size elements checked equal-length copy, safe for overlapping slices (§3.3)
.alloc_index(v) -> i64, .alloc_ref(v) -> T&, .free(i) deprecated reusable pools slot reuse (§5.4); prefer stdlib slot_pool<T>
.alloc_slice(n) -> T[:], .realloc_slice(s, n) -> T[:], .free_slice(s) deprecated reusable[] pools slice reuse (§5.4); prefer stdlib slice_pool<T>

For serialization, the byte-count prefix must account for exactly the remaining input. Validation establishes complete elements, representable lengths, in-range tags, booleans encoded as 0 or 1, canonical varints, and valid self-relative targets before exposing a value. A link must name an outer element or the correctly tagged payload of one; interior-field targets are currently unsupported. Failure returns an empty array and false, not a partially verified value. Success owns a copy of the payload. The implementation notes, section 6.9, give the byte contract and current resource limits; section 11 identifies violations still to fix.

Everything else is the standard library: Goose source under stdlib/, reached by import (§11.1) and documented in stdlib.md (the design it came from is design/stdlib_design.md). The math types of §6.1 are its vec module; the C behind math and os enters through extern fn (§7.10).

Deliberately out of scope for v1: error-value conventions (§7.9); move operations for resizables; nested root owners and arrays of root owners; two-way growth arrays; inline compaction / copying GC for pools; mixed-type pools; alignment annotations and explicit vector types (simd fn, §7.12, only widens the instruction set a function is built for); dynamic stacks; labeled break; namespace privacy, re-exports and nesting (§11.1).


Appendix A. Worked examples (informative)

A.1 A list of strings, flat

fn read_words(text: u8[:]) -> u8[][>..] {     // grow-only array of variable strings
    var words: u8[][>..] = [];
    var i = 0;
    while i < text.len {
        let start = i;
        while i < text.len && text[i] != ' ' { i++; }
        words.push(text[start..i]);            // copies the slice into place, flat
        while i < text.len && text[i] == ' ' { i++; }
    }
    return words;                              // NRVO: built on the caller's stack
}

The result is one contiguous block: len | (len | bytes)*. Iteration is sequential, and no allocation happens anywhere.

A.2 A compact tree with relative references

enum Sexp {
    Sym  { name: u8[varint] },
    List { kids: (Sexp..&<varint>)[varint] },  // tiny self-relative links
}
var pool: Sexp..[>..] = [];                    // grow-only pool of variable ADTs

Children are built first (pushed earlier into pool), and parents store tiny backward offsets: (a (b c d)) takes about 21 bytes in total and can be serialized by memcpy.

A.3 A growable hash table (open addressing), in-model

struct Map { slots: Slot[>..] }           // Slot fixed-size; grow-only

To grow the table, slots.append(…) doubles it in place (a bump, with no move) and then rehashes in place by cycle-walking. Alternatively, rebuild into a fresh local and copy back with whole-resizable assignment (§4.4). Deletions use tombstones or the reusable mechanism. No allocator is involved, and references are not invalidated during lookup.

A.4 Case functions as virtuals

enum Node { Num { v: f64 }, Add { l: Node..&<u16>, r: Node..&<u16> } }
recursive fn eval(n: Node.Num&) -> f64 { n.v }
recursive fn eval(n: Node.Add&) -> f64 { eval(n.l) + eval(n.r) }

A.5 Long-distance errors

fn parse_expr(l: Lexer&) -> Expr.. {
    guard l.tokens_remain() else { return Expr.Nothing {}, "eof" from parse; }
    ...
}
// All deep failures land here; the error is the last return value (§7.9).
fn parse(src: u8[:]) -> Expr.., u8[] { ...; return parse_expr(&l), ""; }

A.6 A context of borrowed tables

struct Ctx {                          // a slice and three references: fixed-size
    source: const u8[:],
    nodes: Node[>..]&,
    text: u8[>..]&,
    scratch: i64[>..<]&,              // the recursion's working stack
}
recursive fn walk(c: Ctx&, n: i64, depth: i64) -> i64 {
    let mark = c.scratch.len;
    c.scratch.push(depth);            // above its callers' entries
    ...
    c.scratch.resize(mark);
    return total;
}
fn compile(source: const u8[:]) -> i64 {
    var nodes: Node[>..] = [];        // one local per table
    var text: u8[>..] = [];
    var scratch: i64[>..<] = [];
    ...
    var c = Ctx { source: source, nodes: nodes, text: text, scratch: scratch };
    return walk(c, 0, 1);
}

An ordinary struct owns at most one resizable (§3.4), and a function in a recursive cycle owns none that stays in scope across a call back into the cycle (§7.8). This example owns each table as a local of a driver function and passes the rest of the program one struct of references to them. A root struct can instead own all the tables in one declaration in that driver. The tables are rooted outside the cycle, which grows and shrinks them through c like any pool passed to it (§7.4, §7.8). Since every call back into the cycle passes c on, the views read through c (a node's name copied into text, for example) may also be stored into the other tables inside the cycle. The tables last for one call of the driver: each compilation starts from fresh tables, and no state is global.


Appendix B. TODO / open items

These items were collected from the design discussion, and each still needs work to resolve. The newest, highest-priority items come first. Items that have since been resolved are kept at the end, each with a pointer to where its resolution is described.

0a. Mixed-signedness ergonomics in practice — §6.1's unification deliberately rejects u32 + i32, and u64 against anything signed outside the comparison rule now in §6.1. Watch whether real code (hash kernels, size math against .len's i64) still accumulates casts. This problem only affects u64: u8/u16/u32 all widen implicitly into i64 (§6.3), so the same code on a u32 hash already needs no casts. What remains is a problem for the analysis, not the language: the bounds-check pass (§10.5) follows a u64 value's range through %, & and casts, but drops it as soon as the value is bound to a u64 local, because in general a u64 need not fit i64. A let has one value, so it could carry its initializer's range as §6.1's non-negativity already does. That would be the narrow, checkable version of "track that this u64 fits i64". Until then, the open-addressed map in bench/goose/words.goose keeps the four checks around slots[idx]. 0f. Bounds-check analysis, known gaps (§10.5) — a loop's exit condition is a disjunction (!(i < n && p)), which a difference-constraint domain cannot represent, so post-loop bounds rely on the inferred invariants instead. LLVM has the same limitation and works around it by duplicating blocks so the conditions never merge; that technique is available here too. A value loaded from an array has no known range, so indirect indexing (dist[out[k]], bench/goose/graph_csr.goose) keeps its check unless what is stored has an index type (§3.11). Release-mode wrapping (§6.2) is deliberate and stays, so the analysis proves the absence of wrapping explicitly where it needs to. 0c. Pointee writes through optionals — writing through a narrowed optional works, but there is no way to write through an optional without narrowing it; rebinding to a plain reference first (let r: T& = o;) is the only workaround. This may be acceptable; revisit it with more usage. 0d. Lifetime precision, remaining cases — the checker is still more conservative than the spec in two places. First, long-distance returns (§7.9) written in a named function only carry references to globals/static data. The precise rule, which a function value's return follows, is that the references are rooted at or above the target's frame. Second, the recursive-cycle store rule (§7.8) admits as roots of stored references only globals, free variables, pool parameters and parameters that every call back into the cycle passes on or gives storage outside the cycle. A reference to a caller's fixed-size local that such a call replaces is still pass-down-only inside a cycle, and so is a merged value that may be one rooted at a parameter the cycle passes on. (One-root-per-reference-variable is a language rule, §9.2; a function's result is rooted as a branch's value is, at the innermost of its returns' roots. Writability follows const types; storage no longer removes read-only restrictions, §9.5.) 2. Error propagation sugar — return from is the mechanism; revisit whether a convention/sugar layer (a try-alike) is wanted once idioms emerge. 3. Move operation for resizables — an assignment that leaves the source empty, as the only sanctioned "move". 5. Cycle store rule refinement (§7.8) — the pass-down-only rule is conservative. Parameters that the cycle passes on as they were given are exempt, and so are parameters that every recursive call gives storage outside the cycle, such as what it reads out of a caller's array. A parameter that a recursive call gives other storage of the cycle's own stays pass-down-only, even where every activation's store would outlive what it is given. So does a parameter whose first argument is a local of a recursive function outside the cycle. Explore per-activation reasoning. 6. "Current pool" implicit destinations — allocation without naming the array, bound at compile time to the in-scope array of the right type. 7. Mixed-type pools & pool GC — internal vs external references, inline compaction, copying; interacts with 6. 8. Nested root owners and constant arrays of owners — root structs already package independent resizables (§3.4); nesting and statically bounded owner arrays are follow-up work (design). Two-way growth arrays remain open. 9. Concurrency surface finalization — queue fairness/capacity, select, worker-local init, flat-type relaxation for self-contained relative-reference values (send whole trees through queues). 10. SIMD/alignment — measure whether packed layouts cost real SIMD performance; consider opt-in aligned types if so. Related: narrow-lane elementwise ops (§6.1) should vectorize now that arithmetic runs at the element width; verify with the particle/sum benchmarks. simd fn (§7.12) gives a function versions for the wider instruction sets; what remains open is alignment, and whether a function should be able to say which versions it wants (an AVX-512 version is not faster for every loop). 13. Labels for break — if early-out patterns demand them. 15. Wasm fallback — index-based reference representation details (docs/design/wasm.md compares the options). 16. Relative-reference region tracking — copies of values containing self-relative references are currently rejected outright (§3.9; the in pool form already copies); track the region an offset ranges over so whole-region copies (and serialization moves) can be proven safe.

Resolved

0e. Per-array index types — DONE, see §3.11 and §7.14 (docs/design/index_types.md): u32 in a is an index validated once against a grow-only global a, which then never shrinks. So indexing a with it needs no check, and neither does indexing another array once their lengths are related. checkfree fn makes any check left in a function an error. 0b. Reference address identity — DONE, see §3.8: r1 .== r2 / r1 .!= r2 compare references by address; == stays the pointee comparison.

0h. Passing by size class — DONE, see §4.1 (and §7.2, §3.8, §6.5): fixed values connect to destinations by value; non-fixed lvalues bind by reference at reference-typed, untyped and un-annotated destinations and are an error at value-typed ones, which take an rvalue or an explicit copy(x); a redundant & is a warning. 0i. Resizable tails get their own frame header — DONE, see C.2: a resizable-tailed struct with an all-fixed prefix is a frame object, so &s.tail is an ordinary reference; variable-size prefixes and resizable-class ADTs keep the bytes-on-stack shape. 0j. Slices of grow-shrink arrays — DONE, see §5.2 (superseding TODO 4): a slice or reference into a [>..<] is created like any other and held by variables only. A shrink while one is live is an error, which names the slice or reference and where it was bound. Specializations record what they shrink so that calls are checked the same way. 0g. Recursive results' roots at back edges — DONE (§7.8, cycle return roots): a cycle is checked in rounds. A back edge's result carries the merge of the roots the cycle's returns give, mapped through that back edge's own arguments. In the first round the result points nowhere yet, and the next round repeats that round's checks with the result. A result holding references is checked by the roots of what it holds, in the same way. One case remains: a back edge that passes storage of its own for a parameter the entry call gave static data gets a holder result that may only be passed down.

  1. varint format benchmark — DONE: ULEB128 was adopted (§3.6). The break-even point against the best branchless format is at ~70–75% single-byte values (a cliff, not a slope); above it ULEB is ~3x faster, below it up to ~3x slower. Revisit only if a per-field format choice is ever wanted for unpredictable-length data.
  2. [>..<] interior-reference relaxation — DONE, see §5.2: the liveness test of §5.1, plus per-specialization shrink summaries for the shrinks it cannot see directly (through a reference, or of a global).
  3. FFI — DONE, see §§7.10–7.11: extern fn binds a Goose signature to a C function, an extern "lib" { ... } block binds its members from a shared library by name when the program starts, and export fn exposes a Goose function through a C-callable wrapper. The flat fixed-size types by value, references and slices to them, and builders (T[>..]&) of them can cross the boundary where supported; other shapes are rejected at the declaration.
  4. Open syntax details — DONE: both trailing-block parameter forms exist (the implicit it and x =>, §7.6), and recursive fn is the keyword alone on a cycle's entry function (§7.8).
  5. Stdlib math types — DONE, see stdlib.md (vec): vec2/3/4<T> with float3 and friends as aliases; the named ops are overloads per size, and elementwise arithmetic is the language's (§6.1).
  6. Serialization of relative-reference structures — DONE, see §12 and docs/design/serialization.md: to_bytes(a) copies an array's element region into a fresh u8[>..], from_bytes<T[>..]>(bytes) verifies an image and returns the array plus a bool. The verifier is generated per element type next to the size and equality walkers, and a rejected image yields an empty array, never an unproven reference (§9.4). An image is little-endian by definition and carries a varint byte count in front, so a reader of a stream can size its read; bytes_of(a) is the payload as a view, for a save that copies nothing. v1 does not support references into a field of an element, from_bytes into a fixed or limited array, or a big-endian host (which aborts rather than writing bytes only it can read).

Appendix C. Representation and calling convention (normative for the C backend)

C.1 Data stacks

Each data stack S has a control block { top: byte* }. For the main program the control block is a global; threads use an array of control blocks reached from a hidden per-thread base pointer (§11.2). Allocation is p = S.top; S.top += size. Scope exit restores the watermark recorded at scope entry. No other runtime state exists.

C.2 Value layouts

Root structs. An owner has independent field storage and a private descriptor of references to those fields. Each field uses its ordinary representation, including its own stack identity for a resizable. A whole-owner reference is a pointer to the descriptor; it does not copy the components. Direct field access may use the component's local/global location without loading the descriptor. The backend may cache component stack tops, lengths and array bases where alias and effect analysis permits, synchronizing them around calls. The descriptor is not a Goose fixed-size value or a foreign ABI. It supplies no byte view, packed offsets, whole-owner copy, or serialization format.

Resizable values. A resizable's element region always tops its data stack (§1.3(2)). A resizable array is represented as a header in the owning frame ({ base: byte*, len: int64 }) plus the element region on the data stack. &v yields the header's address (a fat reference is that pointer plus the stack identity). With the stack identity known statically (or carried as a fat reference or hidden argument), push compiles to *(T*)S.top = e; S.top += size; h.len++.

A resizable-tailed struct whose other fields are fixed-size (and hold no relative reference) is a frame object: a C struct of those fields followed by the tail's own header (or, for a tail that is itself such a struct, its frame object). It is held in the owning frame as a fixed value would be, with only the innermost tail's elements on the data stack. &s is the object's address plus the stack identity, &s.tail the tail header's, so a tail can be referenced, sliced and grown through a reference like any standalone resizable; s.f through either is a plain member access. Copying, passing by value and returning such a value move the frame object where an array moves its header (C.3) and the elements where an array moves its elements. Nesting composes: a struct whose tail is a frame object embeds it.

The two shapes that are not frame objects keep the bytes-on-stack form behind a base pointer: a struct with a variable-size prefix (a u8[] field before the tail) and a resizable-class ADT, whose payload shape is per variant. Their header's base is the value's start on the data stack and len the tail's count. A reference to such a nested tail is a compile error; reference the owning variable instead. A tail of such a shape whose type is a frame object takes the same form: its fixed fields are bytes of the value, where a bare pad inserts nothing (§3.2), and its own tail's count is that header's len, so its tail cannot be referenced either.

C.3 Calling convention

C.4 Guard pages and commit order

Construction rules (§4.2) write stacks front-to-back. Windows commits on fault: the runtime's handler commits a chunk from whichever page was touched, so an allocation that skips ahead without writing (a large limited-array capacity) needs no explicit commit, and nothing depends on touch order. macOS and Linux map the usable region read/write and let the kernel supply physical pages on touch. Fresh pages, including pages discarded by trim_memory, arrive zeroed from the OS. Reused stack memory is not zeroed. This is harmless, since uninitialized slots are unreachable (§5.3). A final gap after each region, never committed, turns runaway growth into a safe abort.


Appendix D. Grammar sketch (informative)

program     := namespace? topdecl*             // imports may precede namespace
namespace   := "namespace" ident ";"
topdecl     := import | struct | enum | typealias | fndecl | externblock | globaldecl
import      := "import" "."? ident ("." ident)* ";" | "import" ident "from" strlit ";"
declname    := ident | ident "::" ident | "::" ident   // the namespace, else the file's
qname       := ident | ident "::" ident | "::" ident   // a declaration reference
struct      := "root"? "struct" declname generics? "{" fieldlist "}"
enum        := "enum" declname generics? "{" variant ("," variant)* ","? "}"
variant     := ident ( "{" fieldlist "}" )?
fieldlist   := field ("," field)* ","?
field       := ("let" | "const")? ident ":" type ("=" expr)? | "pad" intlit?
typealias   := "type" declname "=" type ";"
generics    := "<" ident (":" type)? ("," ident (":" type)?)* ">"

fndecl      := "checkfree"? ("export" strlit? | "extern" strlit?)? "checkfree"? "simd"?
               "inline"? "recursive"? ("fn" | "thread_fn")
               declname generics? "(" params? ")" ("->" rettypes)?
               (blockexpr | ("=" strlit)? ";")
                                                 // "=" strlit: extern only, its C symbol
                                                 // "extern" strlit: deprecated, the same
                                                 // checkfree at most once (§7.14)
                                                 // nested: ident only
externblock := "extern" strlit "{" externmember* "}"   // §7.10: the library, then its functions
externmember := "fn" declname "(" params? ")" ("->" rettypes)? ("=" strlit)? ";"
params      := param ("," param)* ","?
param       := "var"? ident (":" type ("=" expr)?)?
                                                 // untyped => generic; defaults
                                                 // trailing, not in a block (§7.1)
rettypes    := type ("," type)*                  // no parens in declarations
globaldecl  := ("reusable" ("[" "]")?)? ("let" | "var" | "const") declname (":" type)? ("=" expr)? ";"
                                                 // no initializer: var with a type only

type        := "const"? (prim | qname tyargs? | "(" type ")") postfix*
             | "const"? indextype                // no postfix (§3.11)
                                                 // const: the first & or [:] (§9.5)
indextype   := ("u8"|"u16"|"u32"|"u64") ("in" | "upto") qname
                                                 // parenthesized as a for binder's type
prim        := "bool"|"varint"|"i8"|…|"u64"|"f32"|"f64"
             | "fn" ( "(" types? ")" ("->" (type | "(" types ")"))? )?
tyargs      := "<" type ("," type)* ","? ">"
uint        := "u8"|"u16"|"u32"|"u64"|"varint"
postfix     := "[" expr "]"                      // fixed array (const expr)
             | "[" uint? "]"                     // variable array
             | "[" ".." expr? "]"                // limited (const expr)
             | "[" ">" ".." "<"? "]"             // resizable
             | "[" ":" "]"                       // slice
             | "&" ("<" uint ("in" qname)? ">")?  // reference / relative
             | "?"                               // optional (any type)
             | ".."                              // variable-mode ADT
             | "." ident                         // variant type

stmt        := decl | assign | incdec | guardstmt | deferstmt | exprstmt | constfor | constif
decl        := ("reusable" ("[" "]")?)? ("let" | "var" | "const") identlist (":" type)?
               (("=" | ".=") exprlist)? ";"     // .= binds by reference (§3.8)
assign      := lvalue assignop expr ";"          // = .= += -= *= /= %= &= |= ^= <<= >>=
incdec      := lvalue ("++" | "--") ";"
exprstmt    := expr ";"
guardstmt   := "guard" expr ("else" blockexpr | ";")  // guards the rest of its block (§6.4)
deferstmt   := "defer" (blockexpr | assign | incdec | exprstmt)  // runs at the block's exits (§6.6)
constfor    := "const" "for" "<" identlist ">" "in" source blockexpr   // §6.5; also an
source      := ("fields" | "variants" | "types") tyargs                // initializer or arm
             | "[" exprlist? "]"                                       // list in those lists
constif     := "const" "if" expr blockexpr ("else" (constif | blockexpr))?  // §6.5; likewise
                                                 // expr: constants, has_default<A>, same<T, U>

expr        := control | binary
control     := ifexpr | matchexpr | blockexpr | loops | jumps
ifexpr      := "if" expr blockexpr ("else" (ifexpr | blockexpr))?
loops       := ("while" expr | "for" forbind "in" iter | "loop") blockexpr
forbind     := "&"? ident (":" type)? ("," ident (":" type)?)?
iter        := expr | expr ".." expr
jumps       := "return" exprlist? ("from" qname)? | "break" expr? | "continue"
matchexpr   := "match" expr "{" arm ("," arm)* ","? "}"
arm         := pattern "=>" expr
pattern     := ident "&"? ident | item ("," item)* | "_"   // a variant and its binder, ...
item        := bound (".." bound)?
bound       := "-"? (intlit | qname)             // qname: a variant or a constant (§8.1)
blockexpr   := "{" stmt* expr? "}"

binary      := (precedence climbing; tightest → loosest)
   postfixe := primary ( "(" args ")" trailingblock?
             | "[" sliceargs "]" | "." ident (tyargs "(" args ")" trailingblock?)?
             | "as" "!"? type )*
   unary    := ("-" | "!" | "~" | "&") unary
   levels   := * / %  →  + -  →  << >>  →  &  →  ^  →  |
             →  < <= > >=  →  == != .== .!=  →  &&  →  ||
primary     := literal | qname | "null" | "self" | "(" expr ")"
             | arraylit | structlit | genericcall | qname tyargs "." ident
genericcall := qname tyargs "(" args ")" trailingblock?
trailingblock := "{" (params "=>")? stmt* expr? "}"
sliceargs   := expr | bound? ".." bound?         // bound := "^"? expr
args        := (expr ("," expr)* ","?)?
arraylit    := "[" (exprlist ","? | expr ";" expr)? "]"
structlit   := (qname tyargs? | varianttype) "{" (fieldinits | fieldinits? "..")? "}"
fieldinits  := fieldinit ("," fieldinit)* ","?   // before "..": the "," required
fieldinit   := (ident ":")? expr

Known parse notes:


Appendix E. Implementation notes (informative)

What the current compiler does where the text above leaves it a choice. The compiler's own description, pass by pass and analysis by analysis, is implementation.md.