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.
| Style | Decision Time | Good For |
|---|---|---|
| Static dispatch | Compile time | Generics, known types, zero-overhead abstractions |
| Dynamic dispatch | Runtime | Plugin 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:
| Optimization | Meaning |
|---|---|
| Inlining | Put function body directly at the call site |
| Constant folding | Compute known values early |
| Dead code removal | Remove unused branches |
| Specialization | Generate code for a specific type |
| Better register use | Optimize 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.