Structs and comptime: The Power of Compile-Time Computation

This article is based on Zig 0.16.

In the previous post, we covered Zig’s error handling and memory management — lightweight try/catch fault tolerance, and the explicit allocator-passing philosophy. Now we enter Zig’s most essential territory: structs and methods, and the soul of Zig — compile-time computation (comptime).

If you come from Go, you’ll appreciate how Zig keeps things simple by defining methods directly inside the struct. If you come from Rust, you’ll see a different take on the impl block pattern. And comptime opens a path to “types as values” metaprogramming beyond Go generics and Rust traits.

Structs and Methods

Zig’s struct syntax is closest to C, but its capabilities go far beyond data containers — methods are defined directly inside the struct, and self is just an ordinary, explicitly declared parameter.

zig
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const std = @import("std");

// Struct definition
const Vec2 = struct {
    x: f32,
    y: f32,

    // Method (defined inside the struct)
    fn length(self: *const Vec2) f32 {
        return @sqrt(self.x * self.x + self.y * self.y);
    }

    fn add(self: *const Vec2, other: Vec2) Vec2 {
        return .{
            .x = self.x + other.x,
            .y = self.y + other.y,
        };
    }

    // Static method (no self needed)
    fn zero() Vec2 {
        return .{ .x = 0, .y = 0 };
    }
};

pub fn main() void {
    const v1 = Vec2{ .x = 3.0, .y = 4.0 };
    const v2 = Vec2{ .x = 1.0, .y = 2.0 };

    const len = v1.length(); // 5.0
    const sum = v1.add(v2);  // {4.0, 6.0}
    const zero = Vec2.zero(); // {0, 0}
}

Key observations:

  • self is not a keyword — in Zig, self is merely a conventional parameter name. You could name it this or any valid identifier. This contrasts sharply with Go’s method receiver syntax (func (v *Vec2) length() f32), where the receiver is a language feature. In Zig, methods are just ordinary functions — they just happen to be defined inside a struct and can be called with dot notation.
  • For Rust developers: Rust’s impl Vec2 { fn length(&self) -> f32 } separates method definitions from type definitions. Zig keeps them together, closer to C++ and Java — all member functions live inside the struct braces.
  • Static methods have no self, called via Vec2.zero(), equivalent to C++ static methods or Rust associated functions in impl blocks.
  • Return values use anonymous struct literals: .{ .x = ..., .y = ... } — inspired by C99’s designated initializers, concise and readable.

Compile-Time Computation (Comptime): Why It Matters

Before diving into syntax, let’s understand why comptime matters.

Rust has const fn — it can execute some functions at compile time, but with strict limitations (no memory allocation, no type manipulation). C++ has constexpr — more capable but still constrained and syntactically complex.

Zig’s comptime goes further: any Zig code can execute at compile time. Allocate memory? Yes. Manipulate types? Yes. Run algorithms of arbitrary complexity? Yes. Compile time is simply “another runtime environment” for Zig, with identical syntax.

This means:

  • Zero-cost generics: no vtables, no dynamic dispatch, no erasure — the compiler expands specialized versions at compile time.
  • Metaprogramming without a second language: no need for Rust macros, Go’s go:generate, or C preprocessors. Zig does it all with the same language.
  • Reflection without runtime cost: @typeInfo is evaluated at compile time, producing pre-computed results.

Basic Usage

Executing arbitrary code at compile time

Use the comptime keyword to mark an expression or block for compile-time execution:

zig
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
const std = @import("std");

// A function that can run at compile time
fn fibonacci(n: u32) u32 {
    if (n < 2) return n;
    return fibonacci(n - 1) + fibonacci(n - 2);
}

// Compile-time execution
const fib_10 = comptime fibonacci(10); // = 55

Note that fibonacci itself is a regular runtime function — no const prefix, no special markers. The comptime keyword at the call site tells the compiler: “run this function at compile time and embed the result in the binary.”

This is comptime’s first level of power: the same code can run at compile time or at runtime. No need to maintain two separate implementations.

Types as first-class citizens: List(comptime T: type)

The most powerful use of comptime is combining it with type parameters — types can be passed as values to functions, and functions can return types.

zig
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// Types are first-class citizens, usable as parameters
fn List(comptime T: type) type {
    return struct {
        items: []T,
        len: usize,

        fn get(self: *const @This(), index: usize) T {
            return self.items[index];
        }
    };
}

// Using generic types
const IntList = List(i32);
const FloatList = List(f64);

For Go developers: Go 1.18+ generics use [T any] type parameter syntax, and the compiler also performs monomorphization. But Zig’s approach is lower-level and more flexible — comptime can accept any compile-time known value, not just types.

For Rust developers: Rust generics require trait bounds (fn get<T: Copy>(...)). Zig doesn’t — as long as the operations in your code are valid for T, it compiles. This is “compile-time duck typing”: does T support [] indexing? If so, it compiles.

Version note (Zig 0.16): In earlier versions of Zig, the @Type built-in could dynamically construct types from a compile-time value describing the type’s structure. Zig 0.16 has removed @Type, splitting it into more specialized built-in functions: @Int, @Struct, @Union, @Enum, @Pointer, @Fn, @Tuple, @EnumLiteral. All examples in this post use @typeInfo only to read type information — a read-only operation unaffected by this change. Even as the type-construction API continues to evolve, the comptime concepts and reflection patterns covered here remain valid.

Compile-Time Reflection

Zig’s built-in @typeInfo function retrieves a structured description of any type at compile time — it returns a tagged union describing the type’s structure (fields, parameters, methods, etc.).

zig
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
fn printTypeInfo(comptime T: type) void {
    const info = @typeInfo(T);

    comptime {
        std.debug.print("Type name: {}\n", .{@typeName(T)});
        std.debug.print("Type size: {} bytes\n", .{@sizeOf(T)});
        std.debug.print("Alignment: {} bytes\n", .{@alignOf(T)});
    }
}

// Usage
printTypeInfo(i32);
printTypeInfo(f64);

Code inside comptime { ... } blocks runs entirely at compile time. The results of print calls are compiled directly into the terminal output. This differs from runtime print — during compile-time execution, std.debug.print targets the compiler’s error/log output, not the runtime stdout.

Other commonly used compile-time type introspection built-ins:

  • @typeName(T): Returns the string representation of a type (compile-time []const u8)
  • @sizeOf(T): Returns the byte size of a type (equivalent to C’s sizeof)
  • @alignOf(T): Returns the alignment requirement of a type

Compile-Time Loops: inline for

The most frequent companion to type reflection is inline for — a loop structure that unrolls at compile time. Regular for iterates at runtime; inline for unrolls at compile time, making it perfect for traversing compile-time sequences — like a struct’s field list.

zig
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
fn tupleLength(comptime T: type) usize {
    const info = @typeInfo(T);
    return info.Struct.fields.len;
}

// Iterate struct fields
fn printFields(comptime T: type) void {
    comptime {
        const fields = @typeInfo(T).Struct.fields;
        inline for (fields) |field| {
            std.debug.print("Field: {}, type: {}\n", .{
                field.name,
                field.type,
            });
        }
    }
}

const Point = struct {
    x: f32,
    y: f32,
    z: f32,
};

// Print all field info at compile time
comptime printFields(Point);

The key distinction between inline for and traditional generic programming: it operates on compile-time “values”, not runtime “objects”. Struct fields are a known set at compile time, and @typeInfo(T).Struct.fields returns a compile-time slice that inline for can unroll item by item.

This means you can build auto-serialization, auto-validation, auto-mapping infrastructure — all at zero runtime cost. With all field names and types known ahead of time, the compiler generates optimal code for you.

For Go/Rust developers: Zig’s comptime is far more powerful than Rust’s const fn or Go’s generics. You can execute arbitrary code at compile time, manipulate types, and even allocate memory (compile-time heap). This is how Zig implements generics and metaprogramming. Rust’s proc macros can achieve similar things, but require maintaining a separate macro syntax and runtime environment. Go’s go:generate is an external-tool workaround. Zig’s answer is simpler: use the same language, the same syntax, at compile time.

Summary

This post covered the core of Zig’s design:

ConceptZigGoRust
Method definitionInside struct bodyMethod receiverSeparate impl block
self/thisExplicit parameterImplicit receiver&self sugar
Generics mechanismcomptime parameterType param [T]Trait bounds
Compile-time computationAny Zig codeRestricted const fn
Type reflection@typeInforeflect packageTrait introspection
MetaprogrammingComptime, same languagego:generate external toolProc macro, different syntax

The core philosophy of comptime can be distilled to one idea: compile time and run time exist on a single continuous spectrum. The same syntax, the same functions, the same evaluation rules — the only difference is the comptime keyword.

The next (and final) post in this series covers Common Standard Library Data Structures: ArrayList, StringHashMap, Allocator in practice, and more. We’ll tie everything together and write some real, usable Zig programs.