Skip to content

Inline Branching

Inline branching means Zig chooses a branch during compilation, not during runtime.

Inline branching means Zig chooses a branch during compilation, not during runtime.

This is closely related to comptime. When a condition is known at compile time, Zig can decide which path to keep before the final program is built.

The result is specialized code.

Runtime Branching

A normal if usually runs at runtime.

const std = @import("std");

pub fn main() void {
    var x: i32 = 10;

    if (x > 5) {
        std.debug.print("large\n", .{});
    } else {
        std.debug.print("small\n", .{});
    }
}

Here, x is a runtime variable.

The program checks x > 5 while it is running. The compiled program must contain code for the condition and both possible paths.

Even if x starts as 10, it is still a var, so Zig treats it as runtime data.

Compile-Time Branching

Now compare this:

const std = @import("std");

fn printByType(comptime T: type) void {
    if (T == i32) {
        std.debug.print("i32\n", .{});
    } else {
        std.debug.print("other\n", .{});
    }
}

pub fn main() void {
    printByType(i32);
}

The value T is known during compilation.

So the compiler already knows this condition:

T == i32

For the call printByType(i32), the compiler keeps the first branch.

Conceptually, the generated function behaves like this:

fn printByTypeForI32() void {
    std.debug.print("i32\n", .{});
}

The unused branch is not needed for this specialization.

Why This Matters

Inline branching lets you write one generic function and still get specific code for each type.

For example, suppose you want a function that describes a value differently depending on its type:

const std = @import("std");

fn describe(comptime T: type, value: T) void {
    if (T == bool) {
        std.debug.print("boolean: {}\n", .{value});
    } else if (T == i32) {
        std.debug.print("integer: {}\n", .{value});
    } else {
        std.debug.print("other value\n", .{});
    }
}

pub fn main() void {
    describe(bool, true);
    describe(i32, 123);
}

The compiler creates specialized behavior for each call.

For describe(bool, true), it uses the bool branch.

For describe(i32, 123), it uses the i32 branch.

There is no need for a runtime type check. The type is already known before the program runs.

Branches Can Return Different Code Shapes

Compile-time branching can select different code, not just different values.

fn maxValue(comptime T: type) T {
    if (T == u8) {
        return 255;
    } else if (T == u16) {
        return 65535;
    } else {
        @compileError("unsupported integer type");
    }
}

Usage:

const a = maxValue(u8);
const b = maxValue(u16);

For u8, the function returns 255.

For u16, the function returns 65535.

For another type, compilation fails:

const c = maxValue(bool);

The compiler rejects this because bool reaches the @compileError branch.

@compileError in Branches

@compileError is useful with inline branching because it lets you enforce rules at compile time.

fn onlyUnsigned(comptime T: type) void {
    if (T != u8 and T != u16 and T != u32 and T != u64) {
        @compileError("expected an unsigned integer type");
    }
}

This call is accepted:

onlyUnsigned(u32);

This call is rejected:

onlyUnsigned(i32);

The error happens during compilation, before the program runs.

This is better than letting invalid code exist and fail later.

Branching on Values

Inline branching is not limited to types.

You can branch on any compile-time known value.

fn repeatMessage(comptime loud: bool) void {
    if (loud) {
        @compileLog("LOUD MODE");
    } else {
        @compileLog("quiet mode");
    }
}

Usage:

comptime {
    repeatMessage(true);
    repeatMessage(false);
}

The compiler knows the value of loud in each call.

So it chooses the branch immediately.

Runtime Values Cannot Control Compile-Time Branches

This does not work:

fn choose(comptime flag: bool) void {
    if (flag) {
        // compile-time selected path
    }
}

pub fn main() void {
    var runtime_flag = true;
    choose(runtime_flag);
}

The parameter flag is marked comptime.

That means the caller must pass a compile-time known value.

But runtime_flag is a runtime variable. Zig cannot use it to choose compile-time code.

The fix is to pass a constant known at compile time:

choose(true);

Or make the function use runtime branching:

fn choose(flag: bool) void {
    if (flag) {
        // runtime selected path
    }
}

Inline Branching with switch

switch also works well with compile-time values.

fn typeName(comptime T: type) []const u8 {
    return switch (T) {
        bool => "bool",
        u8 => "u8",
        i32 => "i32",
        f64 => "f64",
        else => "unknown",
    };
}

Usage:

const name = typeName(i32);

The compiler knows T, so it can choose the i32 branch during compilation.

The result is:

"i32"

Inline Branching and Dead Code

Compile-time branching can remove code that does not apply.

Example:

fn debugPrint(comptime enabled: bool, message: []const u8) void {
    if (enabled) {
        @import("std").debug.print("{s}\n", .{message});
    }
}

If you call:

debugPrint(false, "hello");

Then the compiler knows enabled is false.

The function body does nothing for that specialization.

This pattern is useful for optional features, debug behavior, and configuration flags.

A Common Beginner Mistake

Beginners often expect const to always mean compile time.

It does not.

This value is compile-time known:

const x = 10;

But this value may be runtime data:

const input = readNumberFromUser();

The variable input cannot be changed after assignment, but its value comes from runtime behavior.

So this does not make sense:

fn makeArray(comptime n: usize) [n]u8 {
    return undefined;
}

pub fn main() void {
    const input = readNumberFromUser();
    const buffer = makeArray(input);
    _ = buffer;
}

The array size must be known during compilation.

User input arrives only after the program starts.

Inline Branching Does Not Mean Faster by Default

Inline branching can remove runtime checks, but that does not mean every branch should be compile-time.

Use compile-time branching when the decision is naturally known before runtime:

types

array sizes

feature flags

build options

static configuration

format descriptions

protocol layouts

Do not force ordinary runtime data into comptime.

Runtime data belongs at runtime.

Practical Example: Type-Based Formatting

Here is a small example that prints values differently by 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 == u8) {
        std.debug.print("byte = {}\n", .{value});
    } else if (T == []const u8) {
        std.debug.print("text = {s}\n", .{value});
    } else {
        @compileError("unsupported type");
    }
}

pub fn main() void {
    printValue(bool, true);
    printValue(u8, 65);
    printValue([]const u8, "hello");
}

This function has one source definition, but the compiler specializes it for each type.

If you call it with an unsupported type:

printValue(f64, 3.14);

Compilation fails with your custom error.

Mental Model

Inline branching lets Zig answer a question early.

Runtime branch:

When the program runs, check this condition.

Compile-time branch:

While compiling, choose the correct code path.

That is the core idea.

Use inline branching when the condition belongs to the structure of the program, not to live input from the user, network, file system, clock, or operating system.