← Back to DevBytes

Functional Programming in Zig

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:

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:

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 {

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles