Skip to content

Static Dispatch

Static dispatch means the compiler decides which code to call before the program runs.

Static dispatch means the compiler decides which code to call before the program runs.

This is different from dynamic dispatch, where the program decides which code to call while it is running.

In Zig, static dispatch is common because types are known at compile time. When a function receives a comptime T: type, the compiler can specialize the function for that exact type.

A Simple Example

Look at this function:

fn double(comptime T: type, value: T) T {
    return value + value;
}

Use it like this:

const a = double(i32, 10);
const b = double(f64, 1.5);

The compiler knows that the first call uses i32.

It also knows that the second call uses f64.

So it can produce code specialized for each type.

There is no runtime question like:

What type is this value?

The compiler already knows.

Runtime Dispatch

Runtime dispatch happens when the program chooses behavior while running.

For example, imagine a command-line program:

if (user_choice == 1) {
    runFastMode();
} else {
    runSafeMode();
}

The program cannot know user_choice until runtime. The user enters it after the program starts.

So the branch must exist in the final program.

The CPU checks the condition while the program runs.

Static Dispatch

Static dispatch happens when the choice is known during compilation.

fn runMode(comptime fast: bool) void {
    if (fast) {
        runFastMode();
    } else {
        runSafeMode();
    }
}

Usage:

runMode(true);

The compiler knows fast is true.

So it can keep the runFastMode() path for that call.

The choice has already been made before runtime.

Dispatch by Type

Static dispatch is often based on type.

const std = @import("std");

fn printValue(comptime T: type, value: T) void {
    if (T == bool) {
        std.debug.print("bool: {}\n", .{value});
    } else if (T == i32) {
        std.debug.print("i32: {}\n", .{value});
    } else if (T == []const u8) {
        std.debug.print("string: {s}\n", .{value});
    } else {
        @compileError("unsupported type");
    }
}

Usage:

printValue(bool, true);
printValue(i32, 123);
printValue([]const u8, "hello");

The compiler sees each type and chooses the correct branch.

For bool, it uses the bool path.

For i32, it uses the i32 path.

For []const u8, it uses the string path.

There is no runtime type lookup.

Static Dispatch with switch

A switch is often clearer than a long chain of if statements.

fn defaultValue(comptime T: type) T {
    return switch (T) {
        bool => false,
        u8 => 0,
        i32 => 0,
        f64 => 0.0,
        else => @compileError("unsupported type"),
    };
}

Usage:

const a = defaultValue(bool);
const b = defaultValue(i32);

The compiler chooses the branch from the type T.

The result type also depends on T.

For defaultValue(bool), the return type is bool.

For defaultValue(i32), the return type is i32.

Static Dispatch Avoids Runtime Type Tags

Some languages store runtime type information with values.

That allows the program to ask:

What kind of value is this?

Then the program chooses behavior dynamically.

Zig usually avoids this when the type is known at compile time.

Instead of carrying a runtime type tag, Zig lets the compiler specialize the code.

This can make the runtime code smaller, simpler, and faster.

But it also means the program must know the type before runtime.

Static Dispatch in Generic Containers

Static dispatch is used heavily in generic containers.

fn Box(comptime T: type) type {
    return struct {
        value: T,

        pub fn get(self: @This()) T {
            return self.value;
        }
    };
}

Usage:

const IntBox = Box(i32);
const BoolBox = Box(bool);

Box(i32) and Box(bool) are separate concrete types.

The compiler knows the field type and method return type for each one.

There is no shared runtime object that stores “this box contains an i32” or “this box contains a bool.”

The type itself already says that.

Static Dispatch with Interfaces

Zig does not have traditional object-oriented interfaces.

Instead, Zig often uses compile-time checks.

Suppose we want a function that works with any type that has a write method.

fn writeHello(writer: anytype) !void {
    try writer.writeAll("hello");
}

Here, anytype means the compiler infers the concrete type at the call site.

If the passed value has a compatible writeAll method, the code compiles.

If not, compilation fails.

Example:

try writeHello(file.writer());

The compiler knows the concrete writer type returned by file.writer().

Then it checks whether that type supports writeAll.

This is static dispatch. The method call is resolved from the concrete type during compilation.

anytype Is Compile-Time Generic

anytype is a shorthand for a compile-time generic parameter.

This:

fn printTwice(value: anytype) void {
    @import("std").debug.print("{} {}\n", .{ value, value });
}

behaves like a generic function.

The compiler creates a version for each concrete type you use.

printTwice(10);
printTwice(true);

The first call uses an integer type.

The second call uses bool.

Each call is checked separately.

Compile-Time Interface Checks

You can write explicit checks for expected methods or declarations.

For example, @hasDecl checks whether a type has a declaration.

fn requireReset(comptime T: type) void {
    if (!@hasDecl(T, "reset")) {
        @compileError("type must provide reset");
    }
}

Use it inside a generic function:

fn resetAll(items: anytype) void {
    const Slice = @TypeOf(items);
    const info = @typeInfo(Slice);

    if (info != .pointer) {
        @compileError("expected a slice");
    }

    const Child = info.pointer.child;
    requireReset(Child);

    for (items) |*item| {
        item.reset();
    }
}

The compiler checks the element type before runtime.

If the element type does not provide reset, the program fails to compile.

Static Dispatch Can Produce Clear Errors

Static dispatch catches mistakes early.

Example:

const Counter = struct {
    value: u32,

    pub fn reset(self: *@This()) void {
        self.value = 0;
    }
};

const User = struct {
    name: []const u8,
};

This works:

var counters = [_]Counter{
    .{ .value = 1 },
    .{ .value = 2 },
};

resetAll(counters[0..]);

This fails:

var users = [_]User{
    .{ .name = "Ada" },
};

resetAll(users[0..]);

User has no reset method, so the compiler rejects the call.

That is the main advantage: the error appears before the program runs.

Static Dispatch vs Dynamic Dispatch

Both styles are useful.

StyleDecision TimeGood For
Static dispatchCompile timeGenerics, known types, zero-overhead abstractions
Dynamic dispatchRuntimePlugin systems, heterogeneous collections, late binding

Static dispatch is excellent when the compiler knows the concrete type.

Dynamic dispatch is useful when the program must choose behavior from runtime data.

For example, a plugin loaded from disk at runtime cannot usually be selected purely at compile time.

A command parsed from user input is also runtime data.

Static Dispatch and Performance

Static dispatch can help performance because the compiler sees the exact function being called.

That can allow:

OptimizationMeaning
InliningPut function body directly at the call site
Constant foldingCompute known values early
Dead code removalRemove unused branches
SpecializationGenerate code for a specific type
Better register useOptimize with concrete layout information

But do not use static dispatch only because it sounds faster.

Use it when the decision naturally belongs to compile time.

A Practical Example: Generic min

Here is a small generic min function:

fn min(comptime T: type, a: T, b: T) T {
    return if (a < b) a else b;
}

Usage:

const x = min(i32, 10, 20);
const y = min(f64, 3.5, 1.25);

The compiler checks each call using the concrete type.

If you try a type that cannot be compared with <, compilation fails.

const z = min(bool, true, false);

This is rejected because < does not apply to bool.

The generic function is not loosely typed. It is checked after specialization.

A Practical Example: Compile-Time Strategy

You can choose a strategy at compile time.

const SortMode = enum {
    fast,
    stable,
};

fn sort(comptime mode: SortMode, items: []i32) void {
    switch (mode) {
        .fast => quickSort(items),
        .stable => mergeSort(items),
    }
}

Usage:

sort(.fast, numbers);

The compiler knows the mode.

The generated code can use the selected branch directly.

Use this when the caller should decide the strategy in source code, not from user input.

When Static Dispatch Is the Wrong Tool

Static dispatch is wrong when the choice depends on runtime facts.

Bad fit:

const mode = readModeFromUser();
sort(mode, numbers);

If mode comes from the user, it is runtime data.

The function should accept a normal runtime value instead:

fn sort(mode: SortMode, items: []i32) void {
    switch (mode) {
        .fast => quickSort(items),
        .stable => mergeSort(items),
    }
}

Now the program chooses the branch while running.

That is correct because the information arrives while running.

Mental Model

Static dispatch means:

The compiler knows the exact path before the program runs.

Dynamic dispatch means:

The running program chooses the path.

Zig gives you strong tools for static dispatch through comptime, anytype, generic type functions, inline for, @typeInfo, and compile-time checks.

Use static dispatch when the choice is part of the program’s structure.

Use runtime dispatch when the choice comes from the outside world.