16. Meta (compile-time evaluation)
meta is the only metaprogramming surface. It is Zig's comptime: compile-time evaluation of regular functions and types-as-values.
meta def fib(n: i32) -> i32
if n < 2 then n else fib(n - 1) + fib(n - 2)
end
MAX_TODOS: i32 = fib(20) # 6765, evaluated at compile time
Top-level value bindings are always compile-time-evaluated. The binding form is the marker, the meta keyword is not allowed on the right-hand side of a top-level binding, and the parser rejects MAX_TODOS: i32 = meta fib(20) as redundant. The keyword is still required on meta function declarations (meta def fib) and inside expressions: x: i32 = (meta sq(3)) + 1 is fine, the meta there marking a sub-expression and not the whole right-hand side.
Function bodies read top-level constants freely. A pure def, an empty-effect-row action, an impl method and a trait default body may all compute from one (Dur(nanos=n * _NS), n * _SCALE), and the resolved value is folded in when the module lowers for the runtime. A folded primitive is free at run time; a folded aggregate is not. A string, an int or a bool interns into the constant pool, and a reference loads it with a refcount bump, and for those the fold is the whole story. A list, struct, sum variant or tuple interns as one pool entry too, and a reference is a single LoadConst in place of a push per field. The pool is per-module while the value it names is a heap object in a per-actor heap, and interning alone therefore does not hand a reference the value. Both tiers materialise it once and cache it, and only the first reference builds anything while the rest read a slot and take a handle: the bytecode tier once per actor, the AOT tier once per thread. Naming a folded aggregate inside the function that runs per input therefore costs a slot read and a branch in place of a rebuild, and hoisting it out of the loop and passing it in is no longer the difference it was when the AOT rebuild cost about 860 ns for a small peg grammar. A constant sits at its module-qualified name like every other top-level item (§14), and a reader in another file spells it limits.MAX_TODOS and gets the same folded value, from another constant's initializer included, subject to the ordering restriction below. That one restriction runs the other way: a top-level binding's initializer may call functions, MAX_TODOS: i32 = fib(20) above, and may not call one whose own body reads a top-level constant. The constants are still being resolved when that call would run, and it is a compile error naming the function and the unresolved constant.
Meta evaluation runs through the bytecode interpreter under a per-meta step budget, currently 10_000_000 bytecode steps. Infinite recursion or a runaway loop in a meta def surfaces as a compile error in place of hanging hanki build. Callers that need a different ceiling, embedders bounding untrusted .config.hk files or the hot-reload path, set the budget explicitly when calling the VM. The same fuel idea bounds a whole run at the bytecode tier: hanki run --max-steps N, and hanki test --max-steps, caps total bytecode steps across every actor, and hanki run --max-bytes N caps total heap-object allocation in bytes, both with a --deny and --allow default. Either faults the run in place of letting untrusted code spin forever or exhaust memory. See §23.
The compile-time interpreter has no per-actor heap. It stores aggregate values, structs, sum variants, lists and arrays inline alongside primitives, and comptime code can construct and read them. Aggregates are immutable values with no in-place field write; Array.set produces a copied comptime value, matching its observable runtime semantics. It rejects effectful and actor operations and the intrinsics that do I/O or mint a native resource. It evaluates a vetted set of pure intrinsics: the string methods, the persistent List operations, the numeric Array operations, the hash_u64 primitives, which are pure, total and byte-identical to the runtime's, and a value hashed at compile time lands where the same value hashed at run time does, and the BytesBuilder and BytesReader byte-buffer ops, which is what makes an @encapsulated builder-based serialization fold. Comptime code, and where-predicate folding in particular, can call them. The set is the table below, and a test checks it against the compiler's dispatch each way: an intrinsic listed here is foldable, and one absent from here is not. string.slice in that set is what makes a per-character walk expressible at compile time, s.slice(i, i + 1) being a one-character read, and a comptime scanner over a string literal needs no widening of the set. This is what lets Email.new("")'s value.length > 0 fold to a compile error (§9), an actor's static-default invariant fold at its declaration (§15), and a .config.hk file evaluate to a struct or list value (§21). A meta constant is itself comptime-known, and a predicate that reads one (value.length <= LIMIT) folds against its value like any literal. A predicate or construction that needs an impure or effectful intrinsic is not folded and falls back to the runtime check. Folding is half of reaching a top-level binding: the folded value must also be embeddable as a runtime constant, and the embeddable kinds are the primitives, string, bytes, and struct, list, array, variant and tuple aggregates of those. bytes is what makes a comptime-computed binary table reachable, DFA transitions, a CRC table, or an asset assembled through a BytesBuilder, where List<int> would be an RRB trie descended per lookup. A closure, a live comptime resource, and an in-band ±inf or undefined have no constant form, and a meta const resolving to one is a compile error naming the binding.
The table below lists every comptime-foldable intrinsic. Anything absent from it is rejected at compile time with intrinsic <name> cannot be evaluated at compile time. The exact-tier row is the pair int.pow leaves and returns through, which lets a top-level 10.pow(k) fold; the rest of that family is out until something needs it. The @encapsulated JSON parsers fold through this set, including the strict pair, which makes a comptime JSON constant expressible, and codec.to_bytes and codec.from_bytes fold too, which lets a binary constant be built and read back at compile time.
| Family | Intrinsics |
|---|---|
string | string.length, string.starts_with?, string.contains?, string.find, string.split, string.trim_start, string.trim_end, string.join, string.slice, string.to_ascii_uppercase, string.to_ascii_lowercase, string.to_bytes |
hash_u64 | string.hash_u64, bytes.hash_u64, i32.hash_u64 |
bytes | bytes.compare, bytes.length, bytes.get, bytes.get_or, bytes.to_string |
int | int._parse, u8.to_int, f64._parse, i32.to_u32, u32.to_i32, u32.count_ones, u32.to_int |
| exact tier | int.to_rational, rational.to_int |
| bitwise | i8.bit_and, i8.bit_or, i8.bit_xor, i8.bit_not, i8.bit_shl, i8.bit_shr, i16.bit_and, i16.bit_or, i16.bit_xor, i16.bit_not, i16.bit_shl, i16.bit_shr, i32.bit_and, i32.bit_or, i32.bit_xor, i32.bit_not, i32.bit_shl, i32.bit_shr, i64.bit_and, i64.bit_or, i64.bit_xor, i64.bit_not, i64.bit_shl, i64.bit_shr, u8.bit_and, u8.bit_or, u8.bit_xor, u8.bit_not, u8.bit_shl, u8.bit_shr, u16.bit_and, u16.bit_or, u16.bit_xor, u16.bit_not, u16.bit_shl, u16.bit_shr, u32.bit_and, u32.bit_or, u32.bit_xor, u32.bit_not, u32.bit_shl, u32.bit_shr, u64.bit_and, u64.bit_or, u64.bit_xor, u64.bit_not, u64.bit_shl, u64.bit_shr |
| fixed-width narrowing | i16.to_i8, i16.try_to_i8, i32.to_i8, i32.try_to_i8, i32.to_i16, i32.try_to_i16, i64.to_i8, i64.try_to_i8, i64.to_i16, i64.try_to_i16, i64.to_i32, i64.try_to_i32, u16.to_u8, u16.try_to_u8, u32.to_u8, u32.try_to_u8, u32.to_u16, u32.try_to_u16, u64.to_u8, u64.try_to_u8, u64.to_u16, u64.try_to_u16, u64.to_u32, u64.try_to_u32 |
| fixed-width widening | i8.to_i16, i8.to_i32, i8.to_i64, i16.to_i32, i16.to_i64, i32.to_i64, u8.to_u16, u8.to_u32, u8.to_u64, u16.to_u32, u16.to_u64, u32.to_u64 |
List | list.List.empty, list.List.append, list.List.length, list.List.get, list.List.get_or, list.List.concat, list.List.slice, list.List.update |
Array | array.Array._filled, array.Array._from_list, array.Array.length, array.Array._get, array.Array._get_or, array.Array._set, array.Array._copy_resize |
BytesBuilder | BytesBuilder.new!, BytesBuilder.push!, BytesBuilder.extend!, BytesBuilder.reserve!, BytesBuilder.length!, BytesBuilder.fill!, BytesBuilder.set!, BytesBuilder.finish! |
BytesReader | BytesReader.new!, BytesReader.take!, BytesReader.remaining!, BytesReader.position!, BytesReader.peek!, BytesReader.take_u8!, BytesReader._take_uint_be!, BytesReader._take_uint_le!, BytesReader.checkpoint!, BytesReader.consume_checkpoint!, BytesReader.skip! |
StringBuilder | StringBuilder.new!, StringBuilder.push!, StringBuilder.finish! |
The builder's spare_capacity! is absent. The room a reserve! wins is this compiler's allocator rounding it up, and folding the observation would bake a figure that differs between compiler builds into a constant. The reserve! itself folds, a hint changing nothing a comptime value can see, which is what leaves an @encapsulated encoder foldable once someone adds one to it.
@derive(...) is the standard mechanism for generated impls. Its targets are a struct and a type; on an actor it is refused (H0405), an actor having no shape a generator can walk.
@derive(Eq, Display)
struct Point
x: i32
y: i32
end
Eqgenerates a structural, field-by-field and variant-by-varianteq?.Displaygenerates ato_stringthat renders the value asTypeName(field=value, …)for a struct with fields, the bareTypeNamefor a fieldless struct, and per-variantVariant(payload, …), or the bareVariantfor a unit variant, for a sum type. Payloads are positional and render value-only. Each field and payload is shown through its ownDisplayimpl, and the derive composes recursively: a nested type needs its ownDisplay, primitives being covered by the stdlib. For examplePoint(x=1, y=2), and fortype Shape … Circle(i32) | Rect(i32, i32) | Nil,Circle(2),Rect(3, 4)andNil. Where the default format is not what you want, the TodoDisplaybelow for instance, write the impl by hand.Hashgenerates a structuralhashprop that folds each field and payload hash into anh * 31 + part.hashpolynomial, sum variants seeded by variant index so that they hash apart. It composes recursively, and each part needs its ownHashimpl.HashhasEqas its supertrait (§10), the type also needs anEqimpl: write@derive(Eq, Hash)together, or hand-writeEq; derivingHashalone on a type with noEqis a supertrait error. A struct may itself have a field namedhash: the bare-dot read means the field, field precedence applying, while the derived prop serves trait dispatch forMapandSetkeys. The prop-and-field collision error (§10) is reserved for user-declared props.Encodegenerates a structuralencode!<S: Serializer>(self, s: S), an action with an empty effect row taking the method-ownSerializergeneric (§10), that writes a struct's fields in declaration order, framed bybegin_struct!, or a sum'sbegin_variant!(tag, …)and then the matched variant's payloads, all as format-agnostic serializer events, and one derive serves every format (coreserializer). It composes recursively through each part's ownEncode, the primitives being the fixed widths,f64, and the exact tierint,decimal,rationalandf32, plusstring,bytes,List,OptionandMap, all shipping incore, and through arbitrarily nested generics (List<Option<T>>,List<List<T>>,Map<K, List<V>>), on both tiers. AMapencodes canonically, its entries emitted sorted by each key's serialized bytes, and two==-equal maps built in different insertion orders produce identical bytes, the property content-addressing, hashing-the-bytes and signing rely on. Round-trip is preserved.Decodegenerates the inverse: a static actiondecode!<D: Deserializer>(d: D, depth: int) -> Result<Self, DecodeError>that reads the struct or variant frame, then each field, or a sum's variant tag and then its payloads, from the deserializer cursord, and constructs the value, short-circuiting to aDecodeErroron malformed input. A struct givestake_struct!its exact field count; a sum givestake_variant!the orderedList<int>of every variant's payload count, allowing a framed deserializer to check the selected shape before decoding any payload while an unframed one may ignore both.DecodeErrorisTruncated(int),BadTag(u8, int),BadUtf8(int),TooDeep(int),InvalidValue(validation.ValidationError, int),InvalidByteLimit(int, int),ByteLimitExceeded(int, int, int)orInvalidBodyHeader(int), each containing a byte offset. Standard containers and derived bodies reject depth above 128 withTooDeepbefore reading their frame or fields, then read children atdepth + 1. Custom recursiveDecodeimplementations must preserve this guard; the depth argument does not enforce it automatically.codec.from_bytesseeds it at 0.decode!has no receiver, and it is named on the type (Point.decode!(d, 0)) or reached through a bounded parameter (T.decode!(d, 0), §10).@derive(Encode, Decode)together is a both-tiers round trip, and the core impls and the derive compose through the same nested generics asEncode.
# main.hk
open deserializer
open io
open result
open serializer
@derive(Encode, Decode)
struct Point
x: u8
y: u8
end
def main!(args: List<string>) -> () [io]
ser = BinarySerializer(out=BytesBuilder.new!())
Point(x=1u8, y=2u8).encode!(ser)
# decode reads the value back from a cursor over the written bytes
de = BinaryDeserializer(src=BytesReader.new!(ser.out.finish!()))
round_trips = match Point.decode!(de, 0)
Ok(p) -> p.x == 1u8 and p.y == 2u8
Err(_) -> false
end
print!("#{round_trips}") # true
end
Four further derives are in the standard library, outside this core structural set, and are @derive-only and never auto-synthesised: their use sites, a test parameter, a query Row, a parsed element, a capture tree, do not pin the target type the way .encode! or == do. @derive(Arbitrary) (§20, the arbitrary module) generates a property-test generator. @derive(FromRow) (the sqlite module, §17) generates a from_row(r) -> Result<Self, MapError> that reads each struct field from the column of the same name, int, f64, string, bytes and bool, and Option<_> of those for a nullable column, short-circuiting on the first missing or wrong-typed column. @derive(FromXml) (the xml module, §17) generates a from_xml(element) -> Result<Self, XmlShapeError> reading each struct field from the child element of the same declared name through that type's own FromXml, which composes nested records, with Option<T> an optional child and List<T> the same-named children in document order; attributes are not auto-mapped, and require_attribute reaches them by hand, with no tag syntax. @derive(FromCaptures) (the peg module, §17) generates a from_captures(c) -> Result<Self, CaptureShapeError> reading each field from the capture tagged with its name, through tagged, with Option<T> and List<T> selecting the optional and repeated forms. That is the mapping FromXml applies to child elements, a grammar's tag(name, p) and an XML child being one idea in two syntaxes. It is what makes a grammar written as text win on size: on tools/bench/url_peg.hk the hand-written bridge cost 168 tokens against the grammar's own 136, putting peg 19 percent over the hand-written parser it was measured against, and derived, the whole fixture is 189 tokens against that parser's 255. A sum is refused and never guessed at: a capture tree records which tag matched, and never which alternative of an ordered choice was taken. For all four, a sum, a generic struct, or a field whose type has no mapping is a compile error naming the offending form at the @derive site.
The derivable traits need no @derive at all. A non-generic struct or sum reached where it needs an impl with none in scope has one synthesised on demand, the same impl @derive(...) would generate, recursing through its field types. The trigger is the use: Encode and Decode at .encode! and Type.decode!, or at a T: Encode or T: Decode bound; Display at #{…} interpolation and .to_string(); Eq at any == or !=, a bare concrete == synthesising Eq too, like the ==-through-a-generic case (§10), with no structural-compare fallback; and Hash at a .hash read, which also pulls in its Eq supertrait. Synthesis is demand-driven: a type is required to satisfy a trait only where it is used at one, and a struct that merely contains a non-derivable value is fine until that use. A field that cannot take the trait, a resource, an actor reference, a function value, or a type with no impl of that trait, is a compile error naming the field, and no broken impl. f64 has Encode and Display and no Eq or Hash, and a struct with an f64 field can derive the first two and not the last two. A stdlib container field is asked the same question about the container itself: synthesis recurses into List<T>'s, Option<T>'s and Map<K, V>'s element types, and the container's own impl of that trait is hand-written and has to exist. A List<i32> field therefore derives Display and blocks Hash. () is an ordinary type here. It implements Eq, Hash and Display, and a () field derives those three and blocks only the codec pair, where f64 blocks the two it lacks. Equality on a singleton is no judgement call, there being one value and it equalling itself, and it is what makes Result<(), E> and Option<()> comparable, the form every try_each!-style action returns. Display is not settled by that argument, what () should print being a free choice. It renders as the two characters (), chosen because the derive has to print something for a unit field and that is the only spelling which round-trips to the source, and Slot(tag="x", nothing=()) renders as written. Generic user types derive too: one generic impl is synthesised (impl<A: Encode> Encode<Wrap<A>>) and each instantiation resolves through its type-param dictionaries (§10). A generic type's Decode is reached through a T: Decode bound and not through Wrap.decode!() directly.
There are no AST macros. Where @derive cannot do something, you write the impl by hand.