Skip to content

Compile-Time Dispatch

Generic functions in Zig are specialized at compile time.

Generic functions in Zig are specialized at compile time.

When a generic function is called with concrete types, the compiler generates a version of the function for those types. The selection happens during compilation, not at runtime.

Consider this function:

const std = @import("std");

fn square(x: anytype) @TypeOf(x) {
    return x * x;
}

pub fn main() void {
    const a = square(4);
    const b = square(2.5);

    std.debug.print("{d}\n", .{a});
    std.debug.print("{d}\n", .{b});
}

The output is:

16
6.25

The compiler creates separate versions of square:

square(i32)
square(f64)

Each uses operations appropriate for the concrete type.

There is no runtime type inspection. No hidden dispatch table exists. The generated machine code is direct and specialized.

This is compile-time dispatch.

The mechanism becomes more important with structs.

const std = @import("std");

const Dog = struct {
    pub fn speak(self: *Dog) void {
        _ = self;
        std.debug.print("woof\n", .{});
    }
};

const Cat = struct {
    pub fn speak(self: *Cat) void {
        _ = self;
        std.debug.print("meow\n", .{});
    }
};

fn speak(animal: anytype) void {
    animal.speak();
}

pub fn main() void {
    var dog = Dog{};
    var cat = Cat{};

    speak(&dog);
    speak(&cat);
}

The output is:

woof
meow

The function:

fn speak(animal: anytype)

is instantiated separately for:

*Dog
*Cat

The compiler resolves the method call statically.

This differs from virtual dispatch in languages such as C++ or Java.

In Zig:

  • the concrete type is usually known
  • specialization happens during compilation
  • the generated call is direct

The result is efficient machine code with little abstraction overhead.

Compile-time dispatch also enables type-dependent logic.

const std = @import("std");

fn describe(value: anytype) void {
    const T = @TypeOf(value);

    switch (@typeInfo(T)) {
        .int => std.debug.print("integer\n", .{}),
        .float => std.debug.print("float\n", .{}),
        else => std.debug.print("other\n", .{}),
    }
}

pub fn main() void {
    describe(10);
    describe(3.14);
    describe(true);
}

The output is:

integer
float
other

The switch executes during compilation because the type information is known at compile time.

This allows a single function body to generate different code depending on the argument type.

Compile-time dispatch is heavily used in the standard library.

Examples include:

  • formatting
  • serialization
  • allocators
  • containers
  • parsers
  • testing utilities

The formatter in std.debug.print examines argument types during compilation and generates formatting logic specialized for those types.

For example:

std.debug.print("{d}\n", .{123});
std.debug.print("{s}\n", .{"zig"});

The formatting behavior is selected at compile time from the argument types.

Compile-time dispatch can also remove unused branches entirely.

fn process(comptime debug: bool) void {
    if (debug) {
        @compileLog("debug enabled");
    }
}

When debug is known at compile time, the compiler keeps only the selected branch.

This is different from an ordinary runtime condition.

Compile-time dispatch therefore serves several purposes:

  • selecting operations by type
  • generating specialized code
  • eliminating unused branches
  • implementing generic abstractions efficiently

The language relies on compile-time specialization instead of large runtime object systems.

Exercise 11-17. Write a generic function that prints different messages for integers and floats.

Exercise 11-18. Write a generic function that behaves differently for signed and unsigned integers.

Exercise 11-19. Implement a generic formatter for a small struct.

Exercise 11-20. Write a compile-time boolean parameter that enables or disables logging code.