Functional Programming in Zig: A Developer's Guide
Zig is a systems programming language known for its emphasis on explicitness, minimal runtime overhead, and powerful compile-time metaprogramming. While Zig is fundamentally imperative, it provides a rich set of tools that enable functional programming patterns — from first-class functions and algebraic data types to exhaustive pattern matching and lazy iterators. This tutorial explores how to leverage functional programming techniques in Zig, why they matter for writing robust systems software, and how to apply them effectively in real-world code.
What is Functional Programming in Zig?
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Functional programming (FP) is a paradigm centered on pure functions, immutability, and declarative data transformations. In Zig, FP is not a separate mode of the language but rather a style you adopt by combining several language features:
- Immutability via
constbindings and compile-time values - First-class functions using function pointers and comptime function parameters
- Algebraic data types (sum types) with tagged unions
- Exhaustive pattern matching via
switchon unions and error sets - Option types (
?T) and error unions (!T) for explicit nullability and error handling - Lazy evaluation through custom iterator structs
- Compile-time computation (
comptime) that allows functional pipelines to run entirely at build time
Zig deliberately avoids hidden control flow, exceptions, and implicit closures that capture environments. This means you must be explicit about state, ownership, and memory — which aligns perfectly with the functional philosophy of transparency and referential integrity.
Why Functional Programming Matters in Zig
Adopting functional patterns in Zig yields tangible benefits, especially in the context of systems programming where correctness and predictability are paramount:
- Predictability: Pure functions with no side effects are easy to reason about and test in isolation
- Concurrency safety: Immutable data structures eliminate data races without locks
- Composability: Small, focused functions can be combined to build complex logic with minimal coupling
- Compile-time guarantees: Comptime functional pipelines catch errors at build time and eliminate runtime overhead
- Explicit error handling: Error unions force you to handle failure cases, avoiding unexpected crashes
- Clean state management: Tagged unions model complex state machines with compile-time exhaustiveness checks
By blending FP techniques with Zig's low-level control, you get the best of both worlds: high-level expressiveness without sacrificing performance or transparency.
Core Functional Patterns in Zig
1. Immutability by Default
In Zig, const declares an immutable binding. The value cannot be reassigned, and for structs and arrays, the entire aggregate is immutable. This encourages you to think in terms of data transformation rather than mutation, a cornerstone of functional programming.
const std = @import("std");
pub fn main() void {
const x: i32 = 42; // Immutable — cannot be reassigned
var y: i32 = 10; // Mutable — can be updated
y = y + 1; // Allowed
// x = x + 1; // Compile error: cannot assign to constant
const doubled = x * 2; // Create a new value instead of mutating
std.debug.print("x={d}, y={d}, doubled={d}\n", .{x, y, doubled});
}
When you reach for var, ask yourself: "Can I express this as a transformation returning a new value instead?" Often the answer is yes, and your code becomes simpler and safer.
2. First-Class Functions and Higher-Order Functions
Zig treats functions as first-class values through function pointers. You can pass functions as arguments, store them in structs, and return them — enabling higher-order functions like map, filter, and reduce.
const std = @import("std");
fn add(a: i32, b: i32) i32 { return a + b; }
fn multiply(a: i32, b: i32) i32 { return a * b; }
fn subtract(a: i32, b: i32) i32 { return a - b; }
// Higher-order function: accepts a function pointer
fn apply(op: *const fn(i32, i32) i32, a: i32, b: i32) i32 {
return op(a, b);
}
pub fn main() void {
const r1 = apply(&add, 10, 5); // 15
const r2 = apply(&multiply, 10, 5); // 50
const r3 = apply(&subtract, 10, 5); // 5
std.debug.print("Results: {d}, {d}, {d}\n", .{r1, r2, r3});
}
You can also use comptime function parameters to avoid the indirection of function pointers when the function is known at compile time:
fn applyComptime(comptime op: fn(i32, i32) i32, a: i32, b: i32) i32 {
return op(a, b);
}
pub fn main() void {
const r = applyComptime(add, 10, 5); // Resolved at compile time, zero overhead
std.debug.print("Result: {d}\n", .{r});
}
3. Closures via Capturing Structs
Zig does not have automatic closures that capture lexical environments. Instead, you create explicit capturing structs — structs that hold the captured data as fields and expose a method that uses those fields. This is fully transparent: you see exactly what state is being carried.
const std = @import("std");
// A "closure" that captures an increment value
const Incrementer = struct {
delta: i32,
pub fn apply(self: @This(), x: i32) i32 {
return x + self.delta;
}
};
fn makeIncrementer(delta: i32) Incrementer {
return Incrementer{ .delta = delta };
}
pub fn main() void {
const inc5 = makeIncrementer(5);
const inc10 = makeIncrementer(10);
const a = inc5.apply(100); // 105
const b = inc10.apply(100); // 110
std.debug.print("inc5(100)={d}, inc10(100)={d}\n", .{a, b});
}
This pattern gives you full control over captured state, memory lifetime, and potential modifications — there are no surprises.
4. Algebraic Data Types with Tagged Unions
Tagged unions (sum types) are Zig's mechanism for modeling algebraic data types. They allow a value to be exactly one of several variants, each carrying its own data — perfect for representing expression trees, state machines, protocol messages, and more.
const std = @import("std");
const Expr = union(enum) {
literal: i32,
add: struct { left: *const Expr, right: *const Expr },
mul: struct { left: *const Expr, right: *const Expr },
neg: *const Expr,
};
Each variant is explicitly tagged with its name (literal, add, mul, neg), and the compiler enforces that you handle every variant when pattern matching.
5. Exhaustive Pattern Matching
Zig's switch on tagged unions is exhaustive by default — if you forget a variant, the compiler produces an error. This guarantees that all cases are handled, a hallmark of functional safety.
fn eval(expr: *const Expr) i32 {
return switch (expr.*) {
.literal => |val| val,
.add => |args| eval(args.left) + eval(args.right),
.mul => |args| eval(args.left) * eval(args.right),
.neg => |inner| -eval(inner),
};
// Compiler ensures all 4 variants are covered — no 'else' needed
}
pub fn main() void {