77 KiB
general todos
- sum-type ABI/layout polish
- consider dynamic tag-width shrinking after the fixed-
u16ABI has real pressure - consider all-void channel collapse after fallible channels are otherwise stable
- define cross-module/global-id ABI determinism before multi-module builds depend on it
- keep backed/C enum composition and must-consume fallible linting as later policy work
- consider dynamic tag-width shrinking after the fixed-
milestones
- interop type foundation (implemented)
- unsigned integers, floats, and target-dependent c scalar types
- atomic
c_*primitive types remain distinct until target-aware lowering c_funcand pointer-onlyc_struct;cremains an ordinary identifier- keep binding mutability (
::/=) separate from element or pointee mutability (mut) - arrays and indexing
[N]T: array withNlogical elements[N;S]T: array withNlogical elements followed by sentinelS
- pointers
@T/@mut T: non-null single-item pointer without arithmetic*T/*mut T: non-null many-item pointer with arithmetic- optional pointers represent nullable pointers (i.e.
?@T/?@mut T,?*T/?*mut T)
- slices and slicing
[]T: pointer and length[;S]T: pointer and length with a sentinel invariant- ordinary slices do not guarantee null termination
- string literals as immutable sentinel slices backed by static arrays (superseded by milestone 3.5)
- character literals
- optionals with trapping unwrap and fallback operations
- native structs with compiler-controlled layout
- pointer-only
c_structsupport with target c layoutSome :: c_struct { ... }: defined c-layout structSome :: c_struct: opaque c-layout struct- passing c structs by value was deferred until milestone 4.1
- restricted c header imports (implemented)
- treat an imported header as a synthetic, file-local package namespace
native :: import "relative/path/to/header.h"
- import functions, typedefs, scalar types, and pointers to opaque records
- keep implementation linking separate from header imports
- cache imports by canonical header path and target/include/define configuration
- diagnose unsupported declarations when referenced
- dynamically load libclang behind a replaceable c importer boundary
- c variadic calls (implemented)
- represent c variadics as a fixed parameter count plus a variadic flag
- apply c default argument promotions at call sites
- emit LLVM c-variadic declarations and calls
- keep native brolang variadics and tuple design separate
3.5. sentinel pointers and c strings (implemented)
- add sentinel many-item pointers:
[*;S]T - represent string literals as immutable pointers to statically stored sentinel arrays:
@[N;0]u8 - arrays expose
.lenbut no.ptr; slices and pointers-to-arrays expose sentinel-preserving.ptr - allow pointer-to-array
.len, indexing, slicing, pointer decay, and slice construction without explicit dereference - preserve or forget sentinel information through compatible pointer and slice coercions without copying arrays
- allow zero-terminated immutable byte pointer views to convert to immutable
*c_charand[*;0]c_char - keep
u8andc_chardistinct to preserve target-dependent scalar c semantics - reject general
u8/c_charinterchange, slice-to-pointer coercion, and conversion to mutable c character pointers
- advanced c interop
- by-value records and unions (implemented)
- complete plain imported structs/unions and manual
c_structvalues - fixed C arrays inside imported records
- keyed struct literals and exactly-one-field union literals
- field reads/writes, storage, and fixed-signature calls/returns
- aarch64-macos small aggregate, homogeneous float aggregate, and indirect ABI lowering
- keep incomplete, bitfield, packed, flexible-array, qualified-field, and otherwise non-plain records pointer-only
- keep C variadic record arguments unsupported
- complete plain imported structs/unions and manual
- function pointers and callbacks (implemented)
- imported C function pointer typedefs lower to nullable pointer types
- manual
?*c_func(...) Tcallback type spelling - concrete
c_funcdeclarations/definitions can be passed as callback values - postfix calls through non-null function pointers, including
callback?(...) - fixed and C-variadic callback ABI emission through LLVM indirect calls
- external variables (implemented)
- imported external C object variables lower to direct LLVM external global references
- top-level
constobject variables are read-only from brolang - mutable external scalars/records can be assigned through qualified package globals
- unsupported variable types remain lazy diagnostics when referenced
- object-like macro constants (implemented)
- scalar integer/float literal macros import as immutable globals
CLITERAL(Type){ ... }/(Type){ ... }record literal macros import as immutable globals- function-like macros and non-literal macro expressions remain unsupported
- static inline functions (implemented)
- control flow (implemented)
- boolean expressions (implemented)
booltype withtrue/falseliterals- comparison operators:
==,!=,<,<=,>,>=(numeric operands widen;boolsupports only==/!=) - operators:
and,or,! - lazy evaluation / short-circuit evaluation
- if statements (implemented). example:
if condition { ... } else if { ... } else { ... }- conditions must be
bool; block-scoped locals do not escape their blocks - lowered through new
Label/Br/Cond_BrIR opcodes (alloca-backed locals, no phi nodes)
- conditions must be
- conditional unwrapping for optionals (
?T) (implemented):if val |v| { ... } else { ... }- unwrapvalintovif it is notnone- single immutable binding scoped to the then-block;
vnot visible inelseor after theif |lexes as a newPipetoken; the.Ifreuses ASTname/ HIRlocalto carry the binding (no new statement kind)- new
Optional_Is_Some/Optional_ValueIR opcodes (theUnwrappresence-test + extract, minus the trap)
- single immutable binding scoped to the then-block;
- conditional unwrapping with guard clause (implemented):
if val |v : v >= 10| { ... } else { ... }- enter the then-block whenvalis notnoneand the guard is true - multi-unwrap (implemented; see section below)
- while loops (implemented; operates on boolean conditions). examples:
while condition { ... }- iterate while the condition is truewhile condition : i = i + 1 { ... }- execute the update after each completed iteration- the condition and update may be parenthesized independently for visual clarity
- update targets must already be declared and mutable; loops do not introduce implicit induction variables
- update clauses support ordinary and compound assignment
- ranges (implemented; see section below)
- for loops (implemented; operates on ranges, arrays, slices, and pointers-to-arrays). examples:
for items |item| { ... }- capture just theitemvalue in the array/slice (uses copy semantics, i.e. gets aT)for (&items) |@item| { ... }- capture a pointer to each array element; its@T/@mut Tmutability follows the iterablefor items_slice |@item| { ... }- slices already refer to backing storage and support pointer capture directlyfor items |item, idx| { ... }- captureitemand its index index in the array/slicefor 0..10 |i| { ... }- iterate over the range0..10(exclusive)for 0..=10 |i| { ... }- iterate over the range0..10(inclusive)for 0..(len) |i| { ... }or equivalentlyfor 0..=(len - 1) |i| { ... }- calculating range bounds, expressions must be parenthesized
- for all conditionals/guards, parentheses are optional but allowed for visual clarity
- compound assignment:
+=,-=,*=,/=(implemented)
- added the binary arithmetic operators
-,*,/(previously only+existed);*//bind tighter than+/-, and prefix-(negation) is unchanged - compound assignments preserve the target, operator, and right-hand side explicitly through parsing and checking; lowering computes the target address once, then loads, applies the operation, and stores through that address
- side-effecting index, field-base, and dereference expressions are evaluated once in left-to-right order
- integer arithmetic traps on overflow (
Sub_Checked/Mul_Checkedvia the LLVM.with.overflowintrinsics) and integer/traps on divide-by-zero andINT_MIN / -1; floats follow IEEE (fadd/fsub/fmul/fdiv, no trap) - constant folding (global initializers) covers
-,*,/alongside+
- enums (native and c interop) (implemented; see below)
- native enums are nominal value types with integer runtime representations
- unbacked enums are non-empty, dense, zero-based, and use the smallest fitting unsigned backing
- explicitly backed enums require an integer type and strictly increasing literal values
- enum members support
Type.member,package.Type.member, and contextual.member - enum values support storage, calls/returns, and same-type equality/inequality
- explicitly backed native enums use their backing ABI in
c_funcsignatures and variadic promotion - imported C enum types alias libclang's target-selected integer backing and enumerators import as package constants
- distinct types (implemented; see below)
- nominal declarations preserve identity across packages and reuse the backing runtime representation
- construction uses
Type(value)with exactly one value of the exact backing type - no implicit conversion to or from the backing type
- backing-type operators and reverse explicit conversions remain deferred
- concrete runtime backing types are supported; unresolved,
int,void, function, and opaque backings are rejected
- allow pointer field access pass-through (implemented)
- having a pointer (
ptr) to a struct, we should allow access throughptr.fieldas opposed to mandatingptr^.field
- make slice expressions on array variables implicitly address-taking (implemented)
- zig's slice expression on an array variable handles the address-taking implicitly (nice ergonomics)
arr[a..b]on an array variable now slices without the explicit&; the explicit(&arr)[a..b]pointer-to-array form keeps working unchanged- array rvalues (e.g. a by-value array return) are materialized into a temporary before slicing, matching the for-loop iterable lowering
- c header imports and automatic native brolang bindings (implemented)
brolang translate-c <header.h> [--target ...] [--c-include-path ...] [--c-define ...]prints native.brobindings for a C header to stdout (the offline counterpart of the in-memorynative :: import "x.h"); reuses the libclangcimport.Result- emitter lives in
compiler/translatec;render_typemirrorsloader.translate_c_typeone-to-one so emitted source re-parses to identical types (guarded by a round-trip test) - added a native type-alias declaration
Name :: alias T(parser/lexer/token surface; thetypes.define_alias/.Aliasmachinery already existed) so C typedefs and callback typedefs round-trip - emits functions, complete/opaque structs (collapsing
typedef struct {...} Foo), typedef aliases, and scalar/aggregate/enum-member constants - C unions, external variables, static-inline functions, and unsupported declarations have
no hand-writable spelling and are emitted as
# unsupported in bindings:comments (functions that reference an un-spellable union therefore keep a dangling reference)
undefinedas inspired by zig (implemented):
- allow mutable local declarations with
undefined- undefined values are assigned a poison value (0xaa...)
- allows for something like:
a int = undefined if (condition) { a = 42 } else { a = -2 }
- disallow:
b :: undefinedsince assigning undefined to something that can't change defeats the purpose - disallow assigning
undefinedafter declaration; use optionals andnonefor values that intentionally move back to an empty state
- introduce
floatandrangetype constraints (theintfamily generalized) (implemented)
floatresolves a local binding to any float scalar (f32/f64) via static analysis; widensf32->f64across assignments, mirroring howintpicks the smallest integer- on a local declaration, integer literals satisfy
floatand default tof64(pi float = 3is3.0); a runtime integer (x float = some_i32) stays acannot implicitly converterror rangeis now a spellable type/constraint:r range :: 0..10resolves to the inferred range (element type preserved), andfunc(start, end int) range { return start..end }monomorphizes the result per call. this replaces the priorint-as-passthrough hack that was the only way to forward a range through a functionint/float/rangeconstraints now gate by family in every position (previously params/results were unchecked generic passthroughs):- a local initializer out of family errors instead of silently taking the natural type
- a param rejects an out-of-family argument (
cannot pass f64 to 'int' parameter 'x'); afloatparam accepts an integer-literal argument as f64 (e.g.f(3)) - a function result is narrowed to the constraint's family
- broaden type inference to surrounding context (implemented)
- a slot's concrete type is the join of demands reachable from its declaration,
flowing backward as well as forward to a fixpoint (the existing global/spec
fixpoint in
infer_all), generalizing milestone 13's forward-only resolution - an "open constant" (a global or local with no concrete annotation plus a compile-time
integer initializer) is sign-agnostic until used: a backward demand from any reachable
use picks its family/width as long as the value fits, so
A :: 10followed byB u16 :: Aresolves both to u16 — the literal's smallest-signed default no longer blocks an unsigned demand; absent any demand it defaults to the smallest signed type - a concrete declared type flows backward through a chain of bare-name references:
X :: 1000; Y int :: X; Z i32 :: Yresolves X and Y to i32 (previously they stayed at the literal's i16) - locals resolve identically to globals (no scope asymmetry): demands flow through
bare-name typed declarations, call arguments (a concrete parameter type demands its
argument, e.g.
take_u16(a)), and returns — including from inside a function body back onto a referenced global - at this milestone, demands flow only through bare names; they do not cross arithmetic or other operators, nor back across a call's result (arithmetic is milestone 15; result-to-argument direction is milestone 14.5)
- a non-fitting or family-conflicting demand is not applied (first demand wins); the
genuine mismatch then surfaces as the usual boundary coercion error at the use
(e.g.
C u8 :: BIGwhereBIG :: 100000)
14.5. backward type-demand propagation through call boundaries (DEFERRED)
- a callee's result/return demand flows back through the function body to constrain
the caller's arguments, so
R u32 :: echo(A)(withecho func(p int) int) resolves A to u32 instead of erroring at the call's result coercion - requires reversing the per-call data flow: a specialization's argument types
(
spec.args) become outputs to solve, not just inputs — a new back-edge threaded through every call site and the specialization fixpoint - only meaningful on top of milestone 14's open constants
- broaden type inference to infer type of declaration based on arithmetic expressions too (implemented)
- backward contextual demands now flow through numeric arithmetic (
+,-,*,/, unary-) for integer and float open constants - integer literals can adopt integer or float arithmetic context; float literals can adopt
f32/f64 - unannotated declarations initialized by arithmetic expressions adopt the concrete numeric operand type
- e.g.
a :: 1 b i32 :: a + 2 # a is constrained to `i32` c :: b + 3 # c is constrained to `i32`
- for if statements, allow
if (cond) one-line statementorif some_func(some_arg) one-line statement(instead of forcing eitherif (cond) { block }orif cond { block }) (implemented)
- if statements without a bracketed body must wrap the condition in parentheses UNLESS it's a function call
- brace-less single-statement bodies apply to the then-body, the
else-body, and the unwrap/guard forms (if v |x| stmt); each branch is independent, so braced and brace-less branches mix freely - the parenthesize-or-call rule constrains only the then-branch condition;
elseand the unwrap|...|already delimit, so they need no parentheses - the brace-less statement may sit on the line after the condition
- parser-only change (
parse_branch_bodyincompiler/parser/parser.odin): a brace-less body is just a 1-element statement slice, so the checker and codegen are unchanged
- multi-line strings (implemented; see below)
- a multi-line string is an ordinary string literal under the hood: it lowers to the
same
.Stringexpr /@[N;0]u8type, so the checker, lowering, and codegen are unchanged. only the lexer and parser change. - the lexer (
compiler/lexer/lexer.odin,case '`') collapses consecutive backtick-marked lines into oneMultiline_Stringtoken; the trailing newline after the last line stays a.Newlineso it terminates the statement normally - the parser (
decode_multiline_stringincompiler/parser/parser.odin) strips each line's leading indentation and`, takes the rest of the line raw (no escapes), and joins lines with an implicit\n(no leading/trailing newline); an empty`yields a blank line - the value may sit on the line after
=/::(the existing post-operatorskip_newlinesalready allows this) ++concatenation (the spec's "mixing" examples) is a separate, unimplemented operator and is out of scope here
- add
breakandcontinuestatements (implemented)
breakexits the innermost enclosing loop;continueskips to that loop's next iteration (running thewhileupdate /forindex increment first). Both target the innermost loop only (no labeled break) and carry no value- new
Keyword_Break/Keyword_Continuetokens;Break/ContinueAST and HIR statement kinds (no fields beyond kind/span); parsed byparse_loop_control - the checker tracks loop nesting (
Build_Ctx.loop_depth, bumped around loop-body builds) and rejectsbreak/continueoutside a loop;all_paths_returnno longer treats awhile truewhose body canbreakas non-terminating (so a non-void function that breaks out without returning is correctly diagnosed) - lowering keeps an innermost-last loop-target stack (
State.loops):breakbranches to the loop's exit label,continueto its update/latch label. The range-for routescontinuethrough the end-of-iteration bounds/overflow guard, sofor 0..=255 |b: u8|exits cleanly instead of overflowing the increment - the LLVM emitter opens a fresh recovery block after any terminator (not just
ret), so dead code following abreak/continuebranch stays well-formed
- add
deferstatement (inspired by zig) (implemented; also adds bare block statements)
defer <stmt>runs the statement when the enclosing block scope exits, in reverse (LIFO) order, on every exit path: fall-through,return,break,continue. The deferred statement may be a block (defer { ... })- bare block statements
{ ... }were added as the enabling feature: a{ ... }introduces a nested scope (locals are name-scoped to it; defers inside it fire at the closing brace). A leading{is unambiguous since struct literals are postfix only - the return value is captured before defers run (a
deferthat mutates the returned local can't change what is returned) — the checker spills the return value into a temp local, then flushes, matching Zig returnflushes all active defers;break/continueflush only down to and including the innermost loop body; fall-through flushes the current block's own defers. Deferring areturn/break/continue/defer, areturninside adefer, or abreak/continuethat would escape adeferare all rejected- implemented entirely in lexer/parser/checker (new
Keyword_Defer;Block/DeferAST kinds reusingbody/update;parse_block_statement/parse_defer). No HIR opcode: a bare block is built and spliced inline, and a deferred statement is built once at thedefersite and its hir replayed at each exit, so lowering/codegen are unchanged
- add
yieldstatement (implemented; first pass — value blocks only; see below)
- a
{ ... }on the right of a declaration or assignment is a value block: its final statement must beyield <expr>, which supplies the block's value (the block analogue ofreturn). Supported:x :: { ...; yield v }(untyped — the local takes the yield's natural type),x T = { ... }(coerces toT), andtarget = { ... }(coerces to the target's type, including complex targets likea[i] = { ... }) - the yielded value is captured before the block's defers run (a defer that mutates a
block local can't change what is yielded), reusing the
returnspill-to-temp pattern yieldis valid only as the final statement of a value block. Ayieldnested in anif/loop/inner block, or in a non-value block, is rejected ("'yield' is only valid as the final statement of a value block"); a value block not ending inyieldis rejected too. This no-early-exit restriction keeps it a lexer/parser/checker-only change with no HIR/lowering touch (like milestones 18/19)- new
Keyword_Yieldtoken +.YieldAST stmt (reusesexpr); a block-initializedDeclaration/Assignmentreuses the existingbodyfield withexprinvalid. The checker'sbuild_value_blockbuilds the leading statements (viabuild_blockwith a newclose=falseflag that keeps the scope open), evaluates the final yield, spills and flushes the block's defers, then feeds the value into an ordinaryDeclaration/Assignment. HIR never holds a.Yield(final yield →Declaration/Assignment, misplaced yield →Trap), so lowering/codegen are unchanged - deferred to a later milestone (needs labeled blocks + rules for whether an
if/loop always produces a value, e.g. optionals): yield from insideif/loops, labeled blocks (blk: { yield :blk v }), implicit trailing-expression yield, and yield in match arms /catchhandlers (milestones 21–22)
20.5 yield from if-statements and loops (implemented; see below)
- an
if/for/whileon the right of a declaration or assignment is now a value source, governed by the rule if one path yields, all paths must yield (no optionals-as-a-crutch, so the value is always present and never needs unwrapping):- value-if:
result :: if a { yield 1 } else if b { yield 2 } else { yield 3 }— a mandatoryelse, every branch ends inyield, all branches share a type (the first branch fixes it when untyped; later branches coerce). TypedT =and reassignmenttarget = if …are supported too - value-loop: a labeled body
for/while … blk: { … }whose early exits areyield :blk xand whose body ends in an unlabeled fall-throughyield(the value when the loop completes). The{T, none}yields resolve the result to?T(a pure-ASTnone-scan picks optionality; the first concrete yield fixes the element type). E.g.active_ent_idx :: for 0..10 |i| blk: { if (cond) yield :blk i; yield none }resolves to?usize
- value-if:
- new
blk:/yield :blklabel surface adds onelabelfield to the ASTStmt; no new token (blk:isIdentifier Colon,:blkisColon Identifier). The parser carries a valueif/for/whileas a one-element block-initbody(the sameexpr-invalid signal a value block uses) - no HIR/lowering change (like 18/19/20): a value-if/loop desugars in the checker to a
mutable result slot (a poison-/fall-through-initialized local) that branches/iterations
assign and that is read after the construct — the existing alloca-backed local flow. A
yield :blk xdesugars toslot = x; break, reusing the milestone-18Breaklowering. Each value-if branch is abuild_value_blockcall; the value-loop reuses the ordinary.For/.Whilebuild via a peeled-body copy. HIR never holds a.Yield - errors: an
ifvalue withoutelse; a branch/value-block not ending inyield; a value loop body without a trailing fall-throughyield; ayield :blkwith no matching value loop. The TODO "BAD" loops (unlabeled yield from inside anif, an unbound labeled loop) fall out of these naturally - follow-ups: a branch that early-
returns instead of yielding, unwrap-ifas a value source, andnone-before-concrete typing in untyped loops are done in 20.6; labeled value blocks andyield/breakto an outer loop are done in 20.7
20.6 value if/loop follow-ups (implemented; checker-only)
- a value-if branch may end in
yieldor exit on every path (return/break/continue) —r :: if (ok) { yield x } else { return -1 }. A non-terminating, non-yielding branch is rejected ("a value branch must end with 'yield' or exit on every path"). Checked viaall_paths_exiton the built branch inemit_value_branch - unwrap-
ifas a value source:name :: if opt |v| { yield v * 2 } else { yield d }.emit_value_ifgained an unwrap path mirroring the build-pass.Ifunwrap arm (captures + guard), each branch assigning the slot; the HIR.Ifcarries the unwraps, which the existing lowering already handles. (The simple "unwrap or fallback" case is justorelse—name :: opt orelse d— already a plain expression.) - untyped value loops pre-type their element from the first concrete (non-
none) yield regardless of source order (a capture-scoped probe build,value_loop_element_type), so anoneyielded before any concrete value still resolves the result to?T - still checker-only; no HIR/lowering change
20.7 labels — value blocks + yield/break to an outer loop (implemented; first lowering change)
x :: blk: { …; yield :blk v }— a labeled value block (the disambiguated form of "an if/loop at the end of a block"; an unlabeled trailing if/loop stays ambiguous and is not a value source).yield :blk vexits the block with a value; every path must yield. Carries the same{T, none}→?Ttyping, defer-capture, and reassignment forms as value loopsyield :outer vto an enclosing (non-innermost) value loop/block, plus plainbreak :L/continue :Lto an enclosing labeled loop- a label now names a first-class exit target:
labeladded to the HIRStmt(on.While/.For/.Break/.Continue) and a new HIR.Blockkind (lowers to its body + an exit label). The lowering'sLoop_Ctx/State.loopsbecame a label-keyed exit-target stack (is_loopdistinguishes loops from value blocks; plainbreak/continuetake the innermost loop, a labeled one searches by label). The checker tracks aloop_labelsstack and aYield_Target.defer_floor; ayield :L vdesugars toslot = v; flush defers to L's body; break :L, reusing the milestone-18 break lowering — no new IR opcode, no emitter change - a labeled bare block as a plain statement is also exitable with
break :blk(a HIR.Blockbreak target; not a loop, so unlabeledbreak/continueandcontinue :blkskip it). The checker tracks a parallelloop_is_loopstack so labeledbreakreaches a loop or block whilecontinueand unlabeledbreak/continuereach only the innermost loop - untyped block
none-before-concrete typing now builds the block's leading (yield-free) statements first (a throwaway probe), so a first concreteyield :blkthat references a block local still resolves the result to?T - deferred (
// ponytail:): the samenone-before-concrete typing in an untyped block (or loop) whose concrete yield references a local declared past the first yield (annotate)
- unions and tagged unions (implemented; first pass — native untagged unions only; see below)
- inspired by zig
-
# unions (untagged; named fields like a struct — Zig union members are always named) SomeStuff :: union { f float i int a Animal } # tagged unions (constrained to the backing enum) — see 21.5 AnimalNameOrHeight :: union(Animal) { # use just `enum` instead of `Animal` for unconstrained tagged union dog []u8 cat []u8 bird int lizard int } - this first pass ships native untagged unions with named fields. an untagged union is a
carrier sized to its largest/most-aligned member with no runtime tag (a C union); reading a
non-active field reinterprets the bytes (unsafe, like a Zig
union {}in ReleaseFast) and an untagged union is not matchable. the TODO's original bare type-only spelling (union { float, int, Animal }) was dropped in favor of named fields to match Zig and to reuse the existing field-access path - the entire untagged-union backend already existed from the C-interop work (milestone 4): the
.Uniontype kind, carrier-based LLVM layout (emit_types),size/alignment, keyed-literal constructionVal{ field = value }(exactly one initializer; the active-field index rides inhir.Expr.integer), and field access (GEP-at-offset-0 reinterpret). none of these are gated onc_layout, so a native union flows through them unchanged. record-declaration validation (is_runtime_valueper field) already covers native unions too - the change is front-end only: a new
unionkeyword (token/lexer), andparse_unionis justparse_structparametrized withis_union=truerouting throughtypes.define_record(native-only, soc_layout=falseand the no-body case errors). notypes/checker/hir/lower/llvmchange (mirrors the minimal-footprint slices of 18/19/20) - deferred to 21.5: tagged unions (
union(Enum)constrained +union(enum)inferred) with a runtime{tag, payload}layout, tag init at construction, and tag extraction — the real codegen work and the foundation for milestone 22 (matchwith payload unwrapping)
21.5. tagged unions with a runtime tag (implemented; see below)
union(Enum)(variant names constrained to an existing enum's members) andunion(enum)(compiler-synthesized anonymous tag enum, one dense 0-based member per variant); both store the discriminant beside the payload as{tag, payload}- a tagged union is a first-class
.Uniontype node whosechildholds the tag enum (untagged unions and structs leavechildINVALID). its fields are the variants (payload types), still looked up by name.union(enum)synthesizes its tag enum up front (types.enum_anonymous, variant names as members), so both forms converge on one representation - key reuse: the per-variant tag value is derivable from
(union type, active variant index)— the variant's field name matches a member of the tag enum, whose value is the tag. so codegen computes the tag itself and the existing.Struct/.FieldHIR (carryingtype+ active/fieldinteger) is sufficient. no HIR/IR/lowering change (like 18/19/20); the delta is parser + types layout + one checker validation + three LLVM emit sites - runtime layout
{tag, payload-carrier}: tag at offset 0, payload carrier atpayload_offset = round_up(sizeof(tag), payload_align)(types.union_payload_offset, shared bysizeand the emitter). field access uses byte-offset GEPs, so offsets stay self-consistent - construction
T{ variant = value }reuses the union-literal path and additionally stores the derived tag; payload readx.variantreuses field access, reading at the payload offset (unchecked reinterpret, Zig-style). safe tag dispatch + payload capture is milestone 22 (match) - changes:
parse_structparsesunion(...)(enum→ synthesize; else an existing enum);types.define_recordtakes atagparam stored inchild, plusis_tagged_union/union_tag_enum/union_payload_offsethelpers and taggedsize/alignment; the checker record-decl pass requires the tag to be an enum and each variant to name a member of it; the LLVM emitter lays out{tag, [pad], carrier, [pad]}, stores the tag in construction, and offsets payload field access - deferred to milestone 22 (
match): safe tag dispatch + payload capture (match x { .bird |v| … }), exhaustiveness checking, and any first-class tag-read accessor
- match statements with tagged unions payload unwrapping (implemented; see below)
match <subject> { <arm>* }dispatches on a tagged union, a plain enum, or a scalar value. Arms are.variant [|cap|]: <body>,<value>: <body>, orelse: <body>; a body is a braced block or a single brace-less statement. Works as a statement and (on a declaration/assignment RHS) as a value source (x :: match … { … }), where a single-expression arm yields implicitly and a{ … }arm ends inyield(or exits on every path), reusing the value-if branch rule from 20.6- tagged unions:
.variant |cap|:binds the payload (cap := subject.variant, the unchecked field reinterpret from 21.5);.variant:ignores it. Exhaustive over the union's variants (its fields), not the whole tag enum - enums:
.member:arms, exhaustive over the enum's members; no capture - scalars (int/float/bool/char): arbitrary-expression patterns compared with
==; anelseis mandatory (the domain can't be enumerated) - exhaustiveness is a compile error: a non-exhaustive enum/union match with no
elselists the missing variants, and a redundantelseon an already-exhaustive match is rejected. Other diagnostics: unknown variant/member, duplicate arm, a non-.variantpattern for an enum/union subject, a capture on a non-union/elsearm, an untagged-union subject, and arms afterelse - the only new runtime capability is reading the discriminant (the 21.5-deferred tag
accessor): a new
hir.Union_Tagexpr +ir.Union_Tagop load the tag enum at the union's offset 0 (the union address is the tag address — no GEP). Everything else is a checker-only desugar to existing HIR (mirrors 18/19/20): the subject is spilled to one temp, the tag read once into another, and the arms become anif tag == .a { … } else if … else { … }chain (the last covered arm is promoted to the unconditionalelseso the chain stays exhaustive andall_paths_returnflows through). Value-match reuses the value-if result-slot pattern (new_value_slot/emit_value_branch); captures reuse the unwrap-if capture scoping. No new HIR/IR statement kinds; lowering/codegen add only the oneUnion_Tagload - parser/AST add a
matchkeyword andMatch/Match_Armstatement kinds (arms stored in theMatch'sbody, pattern inexprwithINVALIDmarkingelse, capture incaptures); aunion(enum)/union(Enum)match resolves variant tags via the same name→tag-enum-member lookup construction uses - deferred (
// ponytail:follow-ups):void-payload variants (pending void) —voidis not a runtime field type yet, so 21.5 can't declare them, though the no-capture arm form is already wired; and multi-pattern arms (.a, .b:) / range patterns
22.5. void-payloads and multi-pattern arms / range patterns in match statements (implemented; see below)
- void-payload variants: a tagged union may declare a
void-payload variant (quit void). As in Zig, a void field carries no runtime value — it is allowed only on a tagged union (the record-decl check skips the runtime-value requirement for it), contributes nothing to the layout (size0 /align1, so it is never the carrier), and is constructed with the bare-key literalT{ variant }(no= value). Construction stores only the tag; codegen skips the payload store. Matched with a plain no-capture arm (.quit:); a capture on a void variant, a= valueon a void variant, a bare key on a non-void field, and a directx.quitpayload read are all diagnosed - multi-pattern arms: an arm may list several patterns (
.a, .b:/0, 1, 2:); the ASTMatch_Armnow carries apatternslist and the checker ORs their dispatch conditions. A capturing multi-pattern arm over a tagged union is allowed when every listed variant has the same payload type (Zig parity — payloads share the carrier offset, so it is one read); mismatched payload types are a "capture group with incompatible types" error - range patterns: a scalar arm may be a range (
0..10:/0..=10:), desugared tokey >= lo and key <(=) hi(existing.Ge/.Le/.Lt/.AndHIR); a range pattern on an enum/union subject is rejected - pointer captures:
|@cap|binds a pointer into the subject's payload (mutate in place), reusing the for-loop@-capture andpointer_captureflag; mutability follows the subject. It requires an addressable subject — verified to need no lowering/codegen change: the subject is spilled as&subjectand captures route through aDeref(lower_location(Deref)is the pointee address), soAddress(Field(Deref(ptr)))aliases the original storage - the only new codegen is the void construction skip (one
llvmsite) plus a one-linelowerguard so a void variant's absent payload operand is not lowered into a trapping recovery value; everything else is parser + checker desugar - deferred (
// ponytail:follow-ups): contextual void construction (e Event = .quit) needs enum-literal→union coercion (milestone 23); Ziginline .a, .b => |v|per-tag comptime captures need monomorphization. Separately, a call expression directly as a match subject (match get()) is a pre-existing gap (assign to a variable first, as the spec examples do); the rvalue pointer-capture guard is defensive for when that lands
22.6. contextual void construction + call-as-match-subject (implemented; see below)
- contextual void construction: a bare enum literal in a tagged-union context constructs a
void-payload variant —
e Event = .quit(and any expected-union position:=, return, call argument) coerces.quitto the union, equivalent toEvent{ quit }. Build-pass only: the.Enum_Literalcase, given a tagged-unionexpected, looks the variant up and emits the tag-only union.StructHIR for a void variant. A payload variant via a bare.variant("needs a payload") and an unknown variant are diagnosed; the payload-carrying contextual form.variant{...}stays deferred to milestone 23 (error channel) - call expression directly as a match subject:
match get() { … }now specializes the call. Root cause was that the inference/spec-request walkerinfer_statementshad no.Matchcase, so a match's subject and arm bodies were never visited and their calls never got a specialization (find_spec→ "could not resolve specialization"). Added a.Matchcase that infers the subject and recurses into arm bodies (with the capture local typed from the variant payload, mirroring the.For/unwrap-.Ifhandling). This also covers value-matchand calls inside arm bodies. As a side effect the rvalue pointer-capture guard from 22.5 is now reachable (match make_box() { .v |@p|: … }correctly errors "requires an addressable subject") - checker-only; no parser/AST/HIR/IR/lowering/codegen change
- deferred: payload-carrying contextual construction (
.variant{…}) remains outside milestone 23 v1. The separateyield call()inference gap is fixed in milestone 23.
- sum-type composition, fallible channels, and yield inference (implemented; v1)
.Yieldis now visited by specialization-demand inference, soyield call()inside value blocks, value-if/value-match branches, and match arms requests the needed call specialization- native unbacked enums and native
union(enum)/union(SomeEnum)variants are registered in a program-global variant table keyed by(name, payload-type). Runtime tags are fixedu16global ids;0is reserved for fallible success/no-error, and overflow is diagnosed - tagged-union construction, tag reads, matching, and LLVM layout use those global ids rather than per-type dense tags. Component-to-composite widening preserves the tag and copies only the active payload carrier bytes from the source carrier offset to the destination carrier offset
A | Bcomposes native unbacked enums and native tagged unions: matching(name, payload-type)variants merge, same-name/different-payload conflicts are diagnosed, and backed enums, C enums, and untagged unions are rejected for v1T ! Eis represented as a real synthetic fallible channel type intypes.Store. A fallible function returns success with code0or an error variant's global id;return exprdispatches based on whetherexprcoerces to the success typeTor the error typeEtry exprunwraps success and propagates the enclosing function's exact error channel;expr catch fallbackunwraps success or evaluates the fallback value- runtime coverage lives in
examples/programs/errors; compiler coverage includes global-id sum merge/conflict/widening/rejections and the.Yieldinference regression - deferred follow-ups are split below; 23.5 keeps the next user-visible slice small
23.5. fallible ergonomics: catch blocks + composable try widening (implemented)
expr catch |e| { ... }bindse : Ein the handler and uses the existing value-blockyieldrules to produce the fallback success valuetrystill requires the same success typeT, but now propagates either the exact error channel or a sum-widenableE1into an enclosingE1 | E2- focused coverage lives in
examples/programs/errorsand the fallible ergonomics compiler test - leave ABI/layout/lint/design polish for later milestones
23.6. contextual payload construction + inline error types (implemented)
- contextual payload
.variant{expr}construction now works in tagged-union contexts (assignment, return, calls, and fallible error dispatch); bare.variantremains the spelling for void-payload variants - named fallible signatures can use inline unbacked enum and
union(enum)error types after!
23.7. anonymous struct payloads and keyed payload sugar (implemented)
- tagged-union variants can use anonymous
struct { ... }payloads, scoped to variant payload declarations rather than general anonymous type syntax - contextual
.variant{field = value, ...}constructs struct payloads by key, reusing ordinary struct-literal validation for unknown, duplicate, missing, and mismatched fields - structurally identical anonymous struct payloads share type identity, so sum composition merges matching variants and still rejects same-name variants with different payload shapes
- bug fixes & interop/indexing oversights (implemented)
return match ...andyield match ...are accepted as direct value-control-flow operands, matching declaration/assignment value sources- index and slice-bound expressions are contextually coerced to
usize; unsigned narrower integer indices work, while signed runtime indices diagnose instead of reaching LLVM - concrete native scalars coerce to same-family C scalar types at call, return, assignment, aggregate, and optional boundaries when the target C type can represent the source width
- scalar keyword casts (
i32(x),usize(x),c_float(x), etc.) provide explicit numeric conversions for cases that should not be implicit - C scalar comparisons accept numeric literals by typing the literal from the concrete C
operand, so aliases like
ZFare no longer needed - array counts accept compile-time integer expressions such as
[CAP]Tand[N + 1]T; runtime variables remain rejected because arrays are fixed-size values - string literals in value-
match/ value-ifpeers resolve to a common zero-terminated byte slice when possible, and[;0]u8slices can decay to immutable*c_char/?*c_charparameters
- dynamic heap allocation (implemented; v1)
std/mem/heapis a tiny relative-importable package over libcmalloc/freeheap.alloc(size usize) ?*mut u8returns nullable mutable byte memory; callers use existing optional unwrapsheap.free(ptr ?*mut u8) voidforwards to Cfree, includingnone/ null- typed allocation, allocator parameters, arenas/pools, build-mode heap policy, and escaping-allocation diagnostics remain deferred
- import from project "root" (implemented)
- imports beginning with
@resolve from the compiler process cwd / project root heap :: import "@std/mem/heap"works from any package depth without../../../path math
- comptime integer value parameters (implemented; v1)
$Nmarks an integer comptime parameter in a normalfuncsignature:make_array func($N usize) [N]u8- callers pass a compile-time integer expression; the value specializes the function and is omitted from the runtime ABI
- inside the specialization,
Nis visible as an immutable compile-time integer in array counts, types, and body expressions - v1 intentionally supports integer values only; no comptime branch pruning or user-function execution
27.5 comptime type parameters (implemented; v1)
$T typemarks an explicit comptime type parameter in a normalfuncsignature:max func($T type, a, b T) T- callers pass the type explicitly as an ordinary comptime argument (
max(i32, a, b)); the type argument specializes the function and is omitted from the runtime ABI - inside the specialization,
Tis visible in parameter, result, local, array, pointer, slice, and fallible type syntax - v1 intentionally keeps
typecontextual to comptime parameter declarations; no inferred type parameters, first-class type values, or comptime execution
27.6 comptime-evaluable constants/functions (implemented)
$exprforces comptime evaluation of an expression:x :: $32,n :: $sum(1, 2), andres :: ${ ... }- constant contexts such as array counts and comptime value arguments implicitly require comptime evaluation; ordinary immutable bindings remain ordinary bindings
- ordinary
funccalls are comptime-evaluable when reached from a comptime context; do not add a separate$sum func(...)declaration form - v1 evaluator supported integer literals/arithmetic, boolean conditions, immutable
locals,
return,if/else, comptime blocks, and direct calls to other evaluable brolang functions - broader typed execution is milestone 27.7
27.7 broader Zig-style comptime execution (implemented; v1)
compiler/checker/comptime.odinowns checker-local evaluator state, typed comptime values, execution, and HIR materialization;checker.odinkeeps type checking, inference, specialization, and build orchestration- typed
$values cover bools, integers, floats, strings, arrays, structs, tagged unions, enums, optionals, and fallibles - supports mutable comptime locals/assignment,
if,while,for,break/continue,defer,match, value blocks/yield, direct calls to bodyful Brolang functions, andtry/catch - successful
$results materialize back into ordinary HIR expressions so lowering and LLVM stay unchanged - evaluation uses a fixed
100_000step quota - immutable locals/globals with comptime-known initializers may feed comptime evaluation; runtime-dependent values remain invalid in comptime contexts
- runtime-only behavior is rejected in comptime: external/bodyless
c_func, writable globals, and materializing comptime storage pointers/slices as runtime memory - v1 keeps integer-only
$Nspecialization keys; aggregate comptime parameters and stable aggregate serialization are deferred
27.8 source-defined mutable runtime globals (implemented)
- allow mutable global declarations in Brolang source for process-global runtime state, matching the writable-global support already needed for imported C globals
- require source type syntax and an initializer; constraints (
int/float/range) and inferred array counts may resolve through the existing inference fixpoint, but the final type must be concrete runtime storage - emit source-defined mutable globals as writable globals, not constants
- allow assignment, address-taking, field/index mutation, and pointer passing under the same mutability rules as other writable locations
- keep mutable globals invalid in comptime evaluation;
$global_varand writes from comptime execution must remain runtime-dependent errors - define initialization order and cycle behavior by reusing the existing global initializer dependency/cycle system where possible
- reject user-visible name shadowing across imports, named types, globals, functions,
params, locals, comptime params, captures, and labels;
_remains reusable
27.9 comptime storage and function values (implemented; practical v1)
- comptime locals, params, and immutable globals can own evaluator storage cells addressable through places instead of compiler-owned memory
- comptime supports address/deref, mutable pointer and slice mutation, field/index
places, slicing,
.len,.ptr, pointer captures, and pointer-param aliasing - comptime storage pointers/slices cannot materialize as runtime memory; escaped dead storage is rejected
- bare concrete non-comptime function names are values; native function pointer
types use
*func(...) Rand fallible*func(...) R ! E - comptime-known native/bodyful
c_funcvalues can be called; bodyless/imported callbacks remain runtime-only - native function pointers are non-variadic v1; C variadic function pointers stay
under
*c_func(...) R
- brolang build system — v0 shipped
brolang build [root]reads a declarativeconfigconstant fromroot/build.bro(importing@std/build'sBuildConfig) and compiles the program package it names. The config is read from the checked HIR — build.bro is never lowered — so it is literal-only.- enabled
&<array literal>(Zig's&.{...}): the literal is promoted to an anonymous global whose address decays to a slice, so list fields likelibraries = &["raylib"]work; empty lists are&[] - deferred: build graph / steps / caching, multiple artifacts, computed paths
(needs string building), struct field defaults to drop
&[]on empty lists
A word on multi-unwrap
Unwrap multiple optionals with and. This short-circuits: if the first optional is none, subsequent expressions are not evaluated.
name: ?[]u8 = get_name()
age: ?u8 = get_age()
if name and age |n, a| {
# both n and a are guaranteed non-none here
print("{s} is {d} years old", n, a)
}
With guard clause on multiple values:
if name and hat |n, h : n == "Huginn" and h.brand == .gucci| {
print("{s}'s got that drip\n", n)
}
Parentheses around the expression are optional, but can aid readability when combined with guards:
# without parentheses
if name and hat |n, h : guard| { ... }
# with parentheses for clarity
if (name and hat) |n, h : guard| { ... }
A word on lazy / short-circuit evaluation
The and in multi-unwrap short-circuits left-to-right:
if get_name() and get_hat() |n, h| {
# get_hat() is only called if get_name() returned non-none
}
This is important for avoiding unnecessary computation or side effects.
A word on ranges
Ranges represent a sequence of values, commonly used in for loops, and is itself a value type:
0..10 # exclusive: 0, 1, 2, ..., 9
0..=10 # inclusive: 0, 1, 2, ..., 10
Parenthesization rule: Each side of .. must be either a simple term (literal or identifier) or a parenthesized expression. This eliminates precedence ambiguity:
0..10 # OK: both sides are literals
0..n # OK: both sides are simple
0..(n + 1) # OK: complex expression is parenthesized
(a + 1)..(b - 1) # OK: both sides parenthesized
# 0..n + 1 # ERROR: must parenthesize complex expressions
This rule keeps the grammar simple and forces clarity at the call site — no precedence rules to remember. Also, being a value type, ranges can be assigned to variables and passed around like any other value. Range bounds are evaluated once, must have compatible concrete integer types, and descending ranges are empty.
For-loop captures are immutable and scoped to the loop body. Sequence index captures are usize. Pointer capture uses |@item|; arrays must be passed by pointer (for example &items), while slices can be used directly. Sentinel elements are not included in iteration.
A word on distinct types
Distinct types are considered distinct from their backing type. They do not implicitly coerce to their backing type.
# distinct type
UserID :: distinct u32
# instantiate distinct type
my_id UserID :: UserID(42) # value must have the exact backing type
A word on enums
# standard enums
Animal :: enum {
dog
cat
bird
lizard
}
# enums with backing type
Nat :: enum(u8) { # in this case, a maximum of 256 values are possible
one # default: implicitly starts from value 0
two
three
four
five
}
# enums with backing type with explicit associated values
# note: must not be jumbled (i.e. `first_val = 1` must come before `other_val = 2`), but is allowed to be discontiguous (i.e. `one = 1` can be followed by `three = 3` without `two = 2` in between)
Nat :: enum(u8) {
one = 1
two = 2
three = 3
# no four
five = 5
}
# enums with backing type with semi-implicit associated values
Nat :: enum(u8) {
one = 1 # starts from value 1
two # implicitly gets value 2
three # etc...
four
five
}
# using enums
dog_tag1 Animal :: Animal.dog
dog_tag2 Animal :: .dog # type inferred
Unbacked enums cannot assign explicit values. Backed enum values must be decimal integer literals, fit the backing type, and increase strictly; gaps are allowed.
Native enum types remain distinct from integers and from other enum types. They support
== and !=, but not arithmetic, ordering, casts, or backing-value extraction.
C enums follow C/Zig import semantics rather than native enum semantics:
native :: import "native.h"
value native.Imported_Enum :: native.IMPORTED_ENUM_VALUE
The imported enum type is an alias of its target-selected C integer backing, and imported enumerators are package-level constants.
A word on multi-line strings
Multi-line strings use the ` character to mark each line. Content starts immediately after the backtick. Newlines between lines are implicit.
config =
`# Database configuration
`host = localhost
`port = 5432
`
`[server]
`address = 0.0.0.0
Key properties:
- Content begins immediately after
` - Newlines are automatically inserted between lines
- Empty
`produces a blank line - No escape sequence processing (raw content)
- No trailing newline after the last line
Only the leading ` is special; the rest is treated as raw content.
If you need a trailing newline, add an empty line at the end:
# No trailing newline
msg =
`hello
`world
# With trailing newline
msg =
`hello
`world
`
Mixing multi-line strings with inline strings (using concatenation):
message =
"Header:\t" ++
`more content here
`even more content
`
++ "Footer"
Formatting alternative (purely aesthetics/preference, no effect on program):
message = "Header:\t" ++
`more content here
`even more content
`
++ "Footer"
A word on yield
The yield keyword provides a value from a block to its enclosing expression and exits the block immediately — just as return exits a function, yield exits the enclosing scope. Code after a yield is unreachable, and the compiler flags it. This makes yield part of a consistent set of scope-exiting control flow: return exits a function, yield exits a block, break exits a loop, and continue skips to the next iteration.
It is used in scoped blocks and match arms today; catch-handler blocks are planned.
General rule: When a block needs to produce a value, single expressions yield implicitly while multi-statement blocks require explicit yield. This rule applies uniformly across the language:
# scoped block
data :: {
result := compute()
yield result
}
# match arms
label []u8 = match p {
.high: "HIGH", # single expression: implicit yield
.low: {
log("low priority")
yield "LOW" # block: explicit yield
},
}
# catch handlers (planned; block form deferred in milestone 23 v1)
data []u8 = read(path) catch |e| {
log(e)
yield fallback_data # block: explicit yield
}
Yielding from if-statements and loops
Yielding from if-statements is possible with the constraint that all branches must resolve to the same yield type.
# yielding to a constant
result :: if a {
yield 1
} else if b {
yield 2
} else {
yield 3
}
# yielding to a variable
result int = if a {
yield 1
} else if b {
yield 2
} else {
yield 3
}
# ILLEGAL: branches with different yield types
result :: if a {
yield 1
} else {
yield Color{ r = 255, g = 0, b = 0 }
}
Yielding is also possible from loops with the same constraint.
# get active entity
active_ent_idx :: for 0..10 |i| blk: {
if is_active(some_entity, i) yield :blk i
yield none # fall-through: no active ent was found (this should imply a return type matching both the index value and `none`, meaning it should resolve to an optional in this case)
# note that in this case, we have to use the `blk` label to yield from the correct scope.
# otherwise, the yield should return directly from the if-statement's scope (which would be incorrect in this case).
}
# BAD: yield returned from if-statement, but no name binds it: should miscompile similar to unused return values from functions.
active_ent_idx :: for 0..10 |i| {
if is_active(some_entity, i) yield i # bad
yield none
}
# BAD: likewise for loops
for 0..10 |i| blk: { # bad, no name binds returned value
if is_active(some_entity, i) yield :blk i
yield none
}
A word on match statements
# matching on enums
match status {
.ok: print("success")
.error: print("failure")
.pending: {
log("still waiting")
retry()
}
}
# matching on integers and other values
match code {
0: print("zero")
1: print("one")
2: print("two")
else: print("other") # needed - missing variants
}
Status :: enum { ok, error, pending }
status :: get_status() # returns a `Status`
match status {
.ok: handle_ok()
.error: handle_error()
.pending: handle_pending()
# no else needed - all variants covered
}
# matching on tagged unions
Result :: union(enum) {
success Data
failure struct {
msg []u8
code i32
}
pending void
}
match result {
.success |data|: { # use `|name|` to capture the variant's payload
process(data)
}
.failure |info|: {
print("error {d}: {s}", info.code, info.msg)
}
.pending: {
# void payload - no capture needed
wait()
}
}
# when a union variant has a `void` payload, omit the capture
Event :: union(enum) {
click struct { x i32, y i32 }
keypress KeyCode
quit void
}
event :: get_event() # returns an `Event`
match event {
.click |pos|: handle_click(pos.x, pos.y)
.keypress |key|: handle_key(key)
.quit: should_exit = true
}
# single-expression arms yield value implicitly
label :: match priority {
.critical: "CRIT"
.high: "HIGH"
.normal: "NORM"
.low: " LOW"
}
# multi-statement arms use `yield`
message :: match code {
0: "success"
1: {
log("warning encountered")
yield "warning"
}
else: "unknown"
}
A word on error handling
Brolang handles errors as values. There is no hidden control flow — a function that can fail declares this in its signature, and the caller must explicitly handle the possibility of failure.
Milestone 23 v1 implements named error channels, native sum composition, return-based error dispatch, exact-channel try, and fallback catch. Milestone 23.5 adds catch |e| blocks and try widening across composable error channels. Milestone 23.6 adds contextual payload construction and inline error types. Milestone 23.7 adds anonymous struct payloads and keyed payload sugar. It still defers the error keyword shorthand and match-on-error shorthand.
Fallible Functions
Functions that can fail declare their error type after !:
read_file func(path []u8) []u8 ! IoError { ... }
This reads as: "returns []u8 or fails with IoError." The space around ! is idiomatic but not required.
Error Types
Errors can be enums (when you only need to identify what went wrong) or tagged unions (when errors need to carry additional context).
Simple errors (enum):
SimpleError :: enum {
OutOfMemory,
InvalidSize,
Timeout,
}
Rich errors (tagged union):
IoError :: union(enum) {
NotFound struct { path: []u8 },
PermissionDenied struct { path []u8, operation []u8 },
Timeout struct { after_ms u64 },
ConnectionReset void, # no additional data needed
}
Constrained tagged union:
When you have a predefined set of error kinds, you can constrain the union:
IoErrorKind :: enum {
not_found,
permission_denied,
timeout,
}
IoError :: union(IoErrorKind) {
not_found struct { path []u8 },
permission_denied struct { path []u8 },
timeout struct { after_ms u64 },
}
Error Composition
Functions that can fail with multiple error types use | to compose a named error type:
ProcessError :: alias IoError | ParseError
process func(path []u8) Ast ! ProcessError { ... }
Parentheses are optional in the composed type and can aid readability:
ProcessError :: alias (IoError | ParseError)
Inline Error Types
Fallible signatures can define small private error channels inline:
read_count func(path []u8) i32 ! union(enum) {
not_found PathErrorInfo
timeout_ms u64
} { ... }
Inline enums are also supported:
parse_flag func(text []u8) bool ! enum {
empty
invalid
} { ... }
Use a named error type when the channel is shared or needs stable public identity.
Returning Errors
Fallible functions use ordinary return for both channels. If the returned expression coerces to the success type T, the function returns success with channel code 0. If it coerces to the error type E, the function returns the error with that variant's global tag id:
parse_section func(p: @mut Parser) void ! ParseError {
start_line Line = p.line
p.advance()
# ... parsing logic ...
if p.pos >= p.input.len or p.input[p.pos] != ']' return .unclosed_section{line = start_line}
# ... continue on success ...
}
Since errors are just union values, you can also construct them separately:
# Construct error value (it's just a union)
e ParseError = .timeout{500}
# Return it via error channel later
return e
The symmetry:
return x— exits with success whenxis the success typereturn e— exits with error wheneis the error type
Propagation with try
The try keyword unwraps a successful result or returns early with the error:
ProcessError :: alias IoError | ParseError
process func(path []u8) Ast ! ProcessError {
data :: try read_file(path) # read_file also returns []u8 ! ProcessError in v1
ast :: try parse(data) # parse also returns Ast ! ProcessError in v1
return ast
}
try propagates when the success type matches the enclosing fallible function and the callee's error channel either exactly matches or can widen into the enclosing composed error channel.
Handling with catch
The catch keyword handles errors and provides a value to continue with. It supports both fallback values and block handlers.
Provide a fallback value:
data :: read_file(path) catch default_data
Block form using yield:
data :: read_file(path) catch |e| {
log("read failed: {}", e)
yield empty_data
}
use(data) # continues with data = empty_data
The yield keyword provides a value from a block to the enclosing expression. Execution continues after the statement. For single expressions, yield is implicit (e.g., catch default_data). For blocks, explicit yield is required. See the Yield section under Control Flow for the full rule.
Planned return-from-handler form (deferred in v1):
data :: read_file(path) catch |e| {
log("read failed: {}", e)
return # exits the enclosing function
}
use(data) # never reached if error occurred
Use return when the error is unrecoverable at this level.
Planned match-on-error form (deferred in v1):
data :: read_file(path) catch |e| match e {
.NotFound |info|: {
print("file not found: {s}", info.path)
yield create_default(info.path)
},
.Timeout |info|: {
print("timed out after {d}ms", info.after_ms)
yield retry(path)
},
.PermissionDenied: panic("cannot recover from permission error"),
.ConnectionReset: retry(path),
}
Summary
| Syntax | Meaning |
|---|---|
T ! E |
Function returns T or fails with E |
| `T ! E1 | E2` |
| `T ! (E1 | E2)` |
return e |
Exit function via error channel when e : E |
error e |
Planned shorthand, not v1 |
.variant{payload} |
Construct a payload-carrying tagged-union variant from one payload expression |
.variant{field = value, ...} |
Construct a struct-payload tagged-union variant from context |
error .variant{...} |
Planned shorthand, not v1 |
try expr |
Unwrap success or propagate an exact/sum-widenable error channel |
expr catch fallback |
Provide fallback value on error |
| `expr catch | e |
yield value |
Provide value from innermost block |
yield :label value |
Provide value from labeled block |
return / return value |
Exit the current function; in fallible functions, return value dispatches by type |
A word on comptime
make_array func($N usize) [N]u8 { ... } # implemented: integer comptime value params
max func($T type, a, b T) T { ... } # implemented: comptime type params
x :: $32 # implemented: force comptime expression evaluation
n :: $sum(1, 2) # implemented: ordinary functions can run at comptime
res :: ${ yield 4 } # implemented: comptime value block
p :: $Point { x = 1, y = 2 } # implemented: typed aggregate comptime values
total :: $sum_loop(4) # implemented: mutable locals/loops/defer/match/try/catch
A word on memory allocation
(NOTE THAT SYNTAX MAY NOT MATCH BROLANG EXACTLY AND SHOULD BE TAKEN WITH A GRAIN OF SALT - INSPIRATION ONLY) (FURTHER, EXAMPLES ASSUME ARGUMENTS WITH DEFAULT VALUES AND COMPTIME POLYMORPHISM IN THE FORM OF GENERIC TYPE PARAMETERS - MONOMORPHISED)
Memory allocation in Brolang is designed to be explicit but not verbose. We reject the dogma that global state is inherently evil — allocators are a cross-cutting concern that nearly every function needs, making them a perfect candidate for sensible defaults.
Philosophy
┌─────────────────────────────────────────────────────────────┐
│ DESIGN PRINCIPLES │
│ │
│ 1. No hidden magic: allocation calls are visible │
│ 2. Sensible defaults: thread-local heap for common cases │
│ 3. Explicit override: custom allocators when needed │
│ 4. Build-mode aware: different behavior for debug/release │
│ 5. Immutable defaults: no "action at a distance" bugs │
│ 6. Escaping allocations: caller provides allocator │
└─────────────────────────────────────────────────────────────┘
The Default Heap Allocator
Brolang provides a thread-local global heap allocator that is:
- Determined at compile time by build mode
- Immutable at runtime — cannot be reconfigured
import "std/mem/heap"
process func(input []u8) u64 {
# heap used for internal temporary work — does not escape
temp := heap.alloc(u8, size: input.len * 2)
defer heap.free(temp)
# ... work with temp ...
return compute_hash(temp) # only the result escapes, not the allocation
}
The behavior of heap depends on build mode:
| Build Mode | Allocator Behavior |
|---|---|
| Debug | Tracking allocator with leak detection |
| Release | Fast allocator, zero overhead |
| ReleaseSafe | Bounds-checking allocator |
This is configured at compile time. You cannot change which allocator heap uses at runtime. This is intentional — it prevents bugs where memory allocated with one allocator is freed with another.
The Escaping Allocation Rule
If a function heap-allocates memory that escapes its scope — whether via the return value or via writes through mutable parameters — the function must accept an allocator parameter. The presence of an allocator parameter is the contract that says "heap memory escapes here, and you're responsible for it."
This rule makes ownership transfer visible at the function signature level. The caller never needs to read the function's implementation to know whether heap cleanup is involved:
import "std/mem"
import "std/mem/heap"
# Allocation escapes via return value — requires allocator
duplicate func(input []u8, allocator @mem.Allocator) []u8 {
result := allocator.alloc(u8, size: input.len)
mem.copy(result, input)
return result # caller manages this memory
}
# Allocation escapes via mutable parameter — requires allocator
init func(obj: @mut MyStruct, allocator: @mem.Allocator) void {
obj.buffer = allocator.alloc(u8, size: 100)
# caller now knows heap memory was written into obj
}
# No allocation escapes — no allocator needed
process func(input: []u8) u64 {
temp := heap.alloc(u8, size: input.len)
defer heap.free(temp)
# ... work with temp ...
return compute_hash(temp)
}
# No heap allocation at all — no allocator needed
reset func(obj: @mut MyStruct) void {
obj.count = 0
}
main func() void {
data := duplicate("hello", heap)
defer heap.free(data)
mut obj := MyStruct{ ... }
init(&obj, heap)
defer heap.free(obj.buffer)
}
Why this matters:
- Without the rule, a function like
init(obj: @mut MyStruct) voidis ambiguous — did it heap-allocate intoobj, or just set some fields to stack/static data? The caller has no way to know without reading the implementation. - With the rule, the allocator parameter is a clear signal: "this function produces heap memory that outlives its scope, and you are responsible for cleaning it up."
- Internal allocations (temporary buffers, scratch space) use
heapdirectly and are freed before the function returns. No allocator parameter needed, no burden on the caller.
The compiler enforces this rule. If a function heap-allocates memory that escapes without accepting an allocator parameter, the compiler emits an error.
Why Immutable Defaults?
Consider what would happen if you could reconfigure the default allocator:
# ❌ THIS IS NOT ALLOWED (and doesn't exist in Brolang)
mem.heap_set(my_custom_heap)
# Somewhere else in the codebase...
data := heap.alloc(u8, size: 100)
# Later, someone changes it again...
mem.heap_set(different_heap)
# Now who frees `data`? With which allocator?
heap.free(data) # 💥 Wrong allocator - undefined behavior!
This is "action at a distance" — the behavior of heap.free() depends on what some unrelated code did earlier. By making heap immutable, Brolang guarantees:
Whatever you allocate with, you free with.
Custom Allocators
For specialized needs, you create explicit allocator instances. These are not global — you manage their lifetime and pass them where needed.
Arena Allocator: Fast bump allocation, bulk deallocation:
import "std/mem"
import "std/mem/heap"
process_file func(path: []u8, allocator: @mem.Allocator) !Data {
# arena manages its own backing memory via heap
arena := mem.Arena.init(heap, capacity: mem.megabytes(1))
defer arena.deinit()
# all temporary allocations from arena (fast bump allocation)
file_contents := arena.alloc(u8, size: file_size)
parsed := arena.alloc(ParsedData) # size defaults to 1
tokens := arena.alloc(Token, size: 1000)
# ... process ...
# escaping allocation uses the caller's allocator
result := allocator.create(Data)
mem.copy(result, parsed)
return result
# arena.deinit() frees all arena memory — no individual frees needed
}
Pool Allocator: O(1) fixed-size allocation, no fragmentation:
import "std/mem"
import "std/mem/heap"
EntitySystem :: struct {
pool: mem.Pool(Entity),
}
init_entities func(allocator: @mem.Allocator) EntitySystem {
return EntitySystem{
pool = mem.Pool(Entity).init(allocator, capacity: 10_000),
}
}
spawn func(sys: @mut EntitySystem) @Entity {
return sys.pool.alloc() # O(1), no fragmentation
}
despawn func(sys: @mut EntitySystem, entity: @Entity) void {
sys.pool.free(entity) # returned to pool for reuse
}
Passing Allocators to Functions
As described in the escaping allocation rule, when a function heap-allocates memory that escapes its scope, it must accept an allocator parameter. The caller decides which allocator to use:
import "std/mem"
# Function that uses caller's allocator
parse func(input: []u8, allocator: @mem.Allocator) !ParseResult {
buffer := allocator.alloc(u8, size: input.len)
defer allocator.free(buffer)
# ... parse into buffer ...
result := allocator.alloc(ParseResult) # size defaults to 1
return result
}
# Caller decides which allocator to use
main func() void {
# use an arena for this parsing work
arena := mem.Arena.init(heap, capacity: mem.kilobytes(64))
defer arena.deinit()
result := parse(input, &arena) catch |err| {
# handle error
}
# or use a pool
pool := mem.Pool(ParseResult).init(capacity: 100)
defer pool.deinit()
result := parse(input, &pool) catch |err| {
# handle error
}
}
Memory Allocation Summary
| What | How | When to Use |
|---|---|---|
heap.alloc(T, size: n) |
Thread-local global | General purpose, 90% of cases |
heap.create(T) |
Thread-local global | Allocate single item |
allocator.alloc(T, size: n) |
Caller-provided | Escaping allocations (returned or written to caller's data) |
arena.alloc(T, size: n) |
Explicit instance | Temporary/scoped work, bulk free |
pool.alloc() |
Explicit instance | Many same-sized objects, O(1) |
Note that heap satisfies the Allocator interface, so callers can pass heap as the allocator argument when they don't need a specialized allocator — which is most of the time.
The golden rule: Allocate and free with the same allocator. The type system helps enforce this — memory from heap can only be freed with heap, memory from your arena can only be freed with that arena. The escaping allocation rule ensures the caller always knows which allocator was used.
Compared to Other Languages
| Language | Approach | Brolang's Advantage |
|---|---|---|
| C | Hidden malloc, easy to mismatch | Explicit allocator at call site |
| C++ | Allocator templates, complex | Simple, no template complexity |
| Rust | Explicit everywhere, verbose | Sensible defaults reduce noise |
| Zig | Allocator parameter threading | Only required for escaping allocations, not internal work |
| Odin | Hidden context parameter | Fully transparent, nothing hidden |
| Go | Hidden GC | Explicit control, no GC pauses |
Brolang sits in a sweet spot: explicit enough to always know what's happening, convenient enough that you don't drown in boilerplate.