· 5 min read
Functional before runtime
Zig's comptime can compose functions and types into specialized programs while the runtime keeps allocation and control flow in plain sight.

If you want a functional Zig, don't teach every slice to say map. Make the compiler the functional part and let the runtime stay bluntly Zig: use comptime to combine behavior before code generation, then emit the loop, the pointer writes, the error path, and the allocation policy the machine actually runs.
Zig already has the pieces for this. Types are values at compile time, functions can take types and other functions as compile-time parameters, and a function can hand back another function. Put those together and comptime stops looking like an unusually capable macro system and starts looking like a small language for building your program out of functions.
fn compose( comptime A: type, comptime B: type, comptime C: type, comptime f: fn (A) B, comptime g: fn (B) C,) fn (A) C { return struct { fn call(value: A) C { return g(f(value)); } }.call;}This compose takes two programs and hands back a third. The intermediate type gets checked, both implementations are fixed, and the returned function needs no runtime registry or closure allocation — by the time a value reaches it, there's nothing left to decide.
Functions build functions#
The Zig language reference says compile-time parameters are how the language implements generics. Types are first-class compile-time values, and the same ordinary function can run during compilation or at runtime when its inputs allow it — so the staging happens in the language you already write, with no second macro dialect pasted on top.
The standard library leans on this constantly. Its sorting functions accept a comparator at comptime while carrying runtime state in an explicit context. The same module's asc(T) and desc(T) helpers return comparator functions generated for whatever type you pick. Zig is already passing programs into programs and getting new programs back; it doesn't advertise it in those words.
fn twice(value: i32) i32 { return value * 2;}fn positive(value: i32) bool { return value > 0;}const doubledIsPositive = compose(i32, i32, bool, twice, positive);John Backus called this direction function-level programming: build programs by applying program-forming operations to other programs. Zig doesn't make that the whole language, but it gives the idea a stage: composition happens in the compiler, and ordinary values show up later.
Functional shape, Zig ownership#
The tempting next move is a whole library of map, filter, folds, and pipelines. That can work, as long as the library keeps Zig's accounting visible: a transform can be selected at compile time without anyone pretending the output storage appears from nowhere.
fn mapInto( comptime A: type, comptime B: type, comptime transform: fn (A) B, input: []const A, output: []B,) void { std.debug.assert(input.len == output.len); for (input, output) |value, *slot| { slot.* = transform(value); }}mapInto has a functional boundary and an imperative body. The caller supplies the input, the output, and with them the allocation decision; the transform is fixed at compile time; the body is still a direct loop writing through a pointer. This is functional programming used to choose the machine code, not to hide the machine.
Runtime captures can stay honest the same way. Zig's sorting API doesn't ask a comparator to smuggle state through a lexical closure — it passes a context value plus a compile-time function that knows how to use it. The environment is plain data, so you can still see who owns that state and how big it is.
Staging is not purity#
Here's where a functional Zig library can lose the language it's supposed to improve. Zig's own overview promises no hidden control flow and no hidden allocation. A generic bind that buries try, a lazy pipeline that conceals a state machine, a collection operator that quietly picks an allocator — each one looks a little more functional and breaks that promise a little more.
So aim narrower. Use compile-time composition for the static stuff: policies, codecs, validators, command tables, kernels whose shape is settled before runtime. Let combinators accept types and functions, reject bad combinations during compilation, and hand back a concrete function or type. Allocators, errors, cancellation, mutation — all of that belongs on the runtime side of the seam.
And keep public APIs discoverable. Generated private machinery is leverage, but method names that only exist after arbitrary compile-time execution are a tax on the editor and on whoever reads the code next. I've chased a method through a codebase where it appeared nowhere in the source, and I don't recommend the experience.
A compiler with an algebra#
A serious library would start small: compose for compatible function types, adapters for optionals and error unions that keep control flow visible, folds that write into caller-owned state, factories that return named, concrete types. Laws still matter here — identity and associativity are how you know a refactored pipeline still means the same thing. Zig's type system and compile-time evaluator can check the shape, and tests can check the behavior.
None of this turns Zig into Haskell, and it isn't trying to. It hands the compiler a small algebra for assembling programs, and it leaves the runtime as plain as Zig promised it would be. That's the version of functional Zig I'd actually want to use.