16. Meta (compile-time evaluation)
meta is the only metaprogramming surface. Conceptually it's 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
end
MAX_TODOS: i32 = fib(20) # 6765, evaluated at compile time
Top-level value bindings are always compile-time-evaluated - the binding shape itself is the marker, so the meta keyword is not allowed on the RHS of a top-level binding; 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 marks a sub-expression, not the whole RHS).
Constants in function bodies. Function bodies read top-level constants freely - a pure def, an empty-effect-row action, an impl method, or a trait default body may all compute from one (Dur(nanos=n * _NS), n * _SCALE); the resolved value is folded in when the module lowers for the runtime. The one ordering restriction runs the other way: a top-level binding's initializer may call functions (MAX_TODOS: i32 = fib(20) above), but not one whose own body reads a top-level constant - the constants are still being resolved when that call would run, so it is a compile error naming the function and the unresolved constant.
Compile-time fuel. Meta evaluation runs through the bytecode interpreter under a per-meta step budget (currently 10_000_000 bytecode steps). Infinite recursion or runaway loops in a meta def surface as a compile error rather than hanging hanki build. Callers that need a different ceiling - embedders sandboxing 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/--allow default), faulting the run rather than letting untrusted code spin forever or exhaust memory - see §23.
Pure values and intrinsics in meta. The compile-time interpreter has no per-actor heap, but it holds aggregate values - structs, sum variants, and lists - inline alongside primitives, so comptime code can construct and read them; aggregates are immutable values (no in-place field write), so this matches the runtime exactly. It still 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 hash_u64 primitives (pure, total, and byte-identical to the runtime's, so a value hashed at compile time lands where the same value hashed at run time does), and the BytesBuilder / BytesReader byte-buffer ops (so an @encapsulated builder-based serialization folds too) - so comptime code, and where-predicate folding in particular, can call them. The set is exactly the table below, which a test holds to the compiler's dispatch in both directions, so 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) is a one-character read, so 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, so 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 simply isn't folded - it falls back to the runtime check.
The comptime-foldable intrinsics. Anything not in this table is rejected at compile time with intrinsic <name> cannot be evaluated at compile time.
| Family | Intrinsics |
|---|---|
string | string.length, string.concat, string.starts_with, string.contains, string.find, string.join, string.slice, string.to_ascii_uppercase, string.to_ascii_lowercase |
hash_u64 | string.hash_u64, bytes.hash_u64, i32.hash_u64 |
List | list.List.empty, list.List.append, list.List.length, list.List.get, list.List.concat, list.List.slice, list.List.update |
BytesBuilder | BytesBuilder.new!, BytesBuilder.push!, BytesBuilder.extend!, BytesBuilder.finish! |
BytesReader | BytesReader.new!, BytesReader.take!, BytesReader.remaining!, BytesReader.position!, BytesReader.peek! |
@derive(...) is the standard mechanism for generated impls:
@derive(Eq, Display)
struct Point
x: i32
y: i32
end
Eqgenerates a structural, field-by-field (and variant-by-variant)eq.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, so they render value-only. Each field/payload is shown via its ownDisplayimpl, so the derive composes recursively - a nested type needs its ownDisplay(primitives are covered by the stdlib). Example:Point(x=1, y=2); fortype Shape … Circle(i32) | Rect(i32, i32) | Nil,Circle(2)/Rect(3, 4)/Nil. When the default format isn't what you want (e.g. the TodoDisplaybelow), write the impl by hand instead.Hashgenerates a structuralhashprop that folds each field / payload hash into anh * 31 + part.hashpolynomial (sum variants seeded by variant index, so they hash apart). It composes recursively - each part needs its ownHashimpl. BecauseHash: Eq(§10), the type also needs anEqimpl:@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 keeps meaning the field (field precedence), while the derived prop serves trait dispatch (Map/Setkeys) — the prop-vs-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, carrying the method-ownSerializergeneric - §10) that writes a struct's fields in declaration order (framed bybegin_struct!), or a sum'sbegin_variant!(tag, …)then the matched variant's payloads - all as format-agnostic serializer events, so one derive serves every format (§ coreserializer). It composes recursively through each part's ownEncode(primitives,string,bytes,List,Option,Mapship incore) and through arbitrarily nested generics (List<Option<T>>,List<List<T>>,Map<K, List<V>>), on both tiers. AMapencodes canonically - its entries are emitted sorted by each key's serialized bytes - so 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/variant frame then each field (or a sum's variant tag then its payloads) from the deserializer cursordand constructs the value - short-circuiting to aDecodeErroron malformed input.DecodeErrorisTruncated(int)/BadTag(u8, int)/BadUtf8(int)/TooDeep(int), each carrying the byte offset. Thedepthargument bounds recursion: a container or derived body reads its children atdepth + 1and rejects past 128 withTooDeep, so a hostile deeply-nested value cannot overflow the decode call stack (parity withjson.parse!; the fuel-less AOT tier's only bound against this - a native stack overflow otherwise).codec.from_bytesseeds it at 0. Becausedecode!has no receiver, 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; the core impls and the derive compose through the same nested generics asEncode.
# main.hk
open bytes_builder
open bytes_reader
open decode
open deserializer
open encode
open io
open list
open option
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
Module-provided derives. Three further derives live in the standard library rather than this core structural set, and are @derive-only - not auto-synthesised, because their use sites (a test parameter, a query Row, a parsed element) don't 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 / bool, and Option<_> of those for a nullable column, short-circuiting on the first missing / 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 via that type's own FromXml (so nested records compose), with Option<T> an optional child, List<T> the same-named children in document order, and attributes deliberately not auto-mapped (reach them by hand via require_attribute - no tag syntax). For all three, a sum, a generic struct, or a field whose type has no mapping is a compile error naming the offending shape at the @derive site.
Auto-derivation. 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 / Decode at .encode! / Type.decode! (or a T: Encode / T: Decode bound); Display at #{…} interpolation and .to_string(); Eq at any ==/!= - a bare concrete == synthesises Eq too, exactly like the ==-through-a-generic case (§10); there is no structural-compare fallback; Hash at a .hash read (which also pulls in its Eq supertrait). Synthesis is demand-driven: a type is only required to satisfy a trait where it actually is used at one, so a struct that merely holds a non-derivable value is fine until that use. A field that cannot carry the trait - a resource, an actor reference, a function value, or a type with no impl of that trait (f64 has Encode and Display but no Eq/Hash, so a struct with an f64 field can derive the first two but not the last two) - is a compile error naming the field, not a broken impl. 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 rather than Wrap.decode!() directly.
There are no AST macros. If @derive cannot do something, you write the impl by hand.