Skip to lesson

learningzig.org / intermediate / 19-generics-and-anytype · lesson 19 of 25

TL;DR

Learn Zig generics and anytype — write reusable, type-safe functions using comptime type parameters with zero runtime overhead.

Key concepts

  • zig generics
  • zig anytype
  • zig comptime type
  • zig generic functions
  • zig type parameters

Generics And Anytype

In most languages, generics are a distinct language feature with special syntax — angle brackets in Java and C++, square brackets in Swift, type parameters in Rust. In Zig, there is no separate generics feature. Instead, types are first-class values at compile time. A generic function is just a function that accepts a type parameter evaluated at comptime. There is no template expansion phase, no separate compilation model, no magic — just ordinary Zig code running at compile time.

This lesson covers two complementary tools: anytype for lightweight duck-typed parameters, and comptime T: type for explicit, inspectable generic programming. Both compile away to zero-overhead concrete code.

anytype: Compile-Time Duck Typing

The simplest way to write a generic function in Zig is with anytype. When you mark a parameter anytype, the compiler infers its concrete type at each call site and generates a specialized version of the function for each unique type used.

const std = @import("std");

fn printValue(value: anytype) void {
    std.debug.print("Value: {any}\n", .{value});
}

pub fn main() void {
    printValue(42);
    printValue(3.14);
    printValue(true);
    printValue("hello");
}

Each call to printValue produces a separately compiled function. There is no boxing, no interface dispatch, no runtime overhead beyond the actual operation. At runtime this is as efficient as writing four hand-specialized functions.

anytype works well for utility functions that apply uniformly to many types — logging helpers, formatters, and simple wrappers. The trade-off is that constraints are implicit. If you pass a type that does not support the operations inside the function, the compiler reports an error pointing into the function internals, which can be difficult to diagnose.

anytype vs Explicit comptime T

Comptime already introduced the explicit form — fn max(comptime T: type, a: T, b: T) T — where the caller supplies a type known at compile time and the parameters and return value all reuse it. The two tools solve the same problem from opposite ends, and the interesting question is when to reach for each.

With anytype, every parameter's type is inferred independently and has no name. To express that two parameters share a type, or to name the type at all, you need @TypeOf, which recovers the concrete type the compiler inferred. @TypeOf(a, b) goes further: it performs peer type resolution, computing the common type of both arguments.

const std = @import("std");

// anytype leaves each parameter's type independent and unnameable.
fn maxAny(a: anytype, b: anytype) @TypeOf(a, b) {
    return if (a > b) a else b;
}

// comptime T: type names the type once and reuses it — a and b must match.
fn maxOf(comptime T: type, a: T, b: T) T {
    return if (a > b) a else b;
}

pub fn main() void {
    // @TypeOf(a, b) is peer type resolution: it finds the common type of a and b.
    std.debug.print("maxAny i32:  {}\n", .{maxAny(@as(i32, -7), @as(i32, 42))});
    std.debug.print("maxAny f64:  {}\n", .{maxAny(@as(f64, 2.71), @as(f64, 3.14))});

    // Explicit T pins both arguments to one named type.
    std.debug.print("maxOf i32:   {}\n", .{maxOf(i32, -7, 42)});
    std.debug.print("maxOf f64:   {}\n", .{maxOf(f64, 2.71, 3.14)});
}

Both functions compile to identical concrete code. The difference is expressiveness. maxAny accepts any two comparable arguments and leans on @TypeOf to describe the result; nothing in its signature forces the two arguments to match, so a mismatch surfaces as an error deep inside the body. maxOf names the type T once and reuses it, so the signature itself guarantees a and b are the same type and documents that contract to any reader.

This is the real reason to prefer explicit comptime T: type: when a type appears more than once in a signature, naming it turns an implicit, error-prone relationship into an enforced, self-documenting one. Reach for anytype when each parameter is independent and the intent is obvious; reach for comptime T: type the moment types need to relate to each other — which is exactly the situation generic data structures create.

Generic Data Structures

The full power of Zig's approach emerges with generic data structures. A generic struct is simply a comptime function that accepts a type and returns a type.

const std = @import("std");

fn Stack(comptime T: type) type {
    return struct {
        items: [64]T = undefined,
        top: usize = 0,

        const Self = @This();

        pub fn push(self: *Self, item: T) void {
            self.items[self.top] = item;
            self.top += 1;
        }

        pub fn pop(self: *Self) ?T {
            if (self.top == 0) return null;
            self.top -= 1;
            return self.items[self.top];
        }

        pub fn peek(self: *const Self) ?T {
            if (self.top == 0) return null;
            return self.items[self.top - 1];
        }

        pub fn isEmpty(self: *const Self) bool {
            return self.top == 0;
        }
    };
}

pub fn main() void {
    var int_stack = Stack(i32){};
    int_stack.push(10);
    int_stack.push(20);
    int_stack.push(30);

    std.debug.print("Peek:  {?}\n", .{int_stack.peek()});
    std.debug.print("Pop:   {?}\n", .{int_stack.pop()});
    std.debug.print("Pop:   {?}\n", .{int_stack.pop()});
    std.debug.print("Empty: {}\n",  .{int_stack.isEmpty()});

    var str_stack = Stack([]const u8){};
    str_stack.push("alpha");
    str_stack.push("beta");
    std.debug.print("Top: {?s}\n", .{str_stack.pop()});
}

Stack(i32) and Stack([]const u8) are two completely separate types generated at compile time. The function Stack itself never runs at runtime — it is evaluated entirely during compilation. @This() refers to the enclosing struct being defined; assigning it to const Self is idiomatic Zig for self-referential method signatures.

Type Constraints with @typeInfo

Both anytype and comptime T: type accept any type by default. Passing a type that cannot support the required operations produces an error message deep inside the function body. You can improve this with @typeInfo, which lets you inspect and constrain types at compile time.

const std = @import("std");

fn sum(comptime T: type, slice: []const T) T {
    switch (@typeInfo(T)) {
        .int, .float, .comptime_int, .comptime_float => {},
        else => @compileError("sum requires a numeric type, got: " ++ @typeName(T)),
    }

    var total: T = 0;
    for (slice) |item| {
        total += item;
    }
    return total;
}

fn average(comptime T: type, slice: []const T) f64 {
    const s = sum(T, slice);
    return @as(f64, @floatFromInt(s)) / @as(f64, @floatFromInt(slice.len));
}

pub fn main() void {
    const ints   = [_]i32{ 10, 20, 30, 40, 50 };
    const floats = [_]f32{ 1.1, 2.2, 3.3 };

    std.debug.print("Int sum:   {}\n",   .{sum(i32, &ints)});
    std.debug.print("Float sum: {d:.1}\n", .{sum(f32, &floats)});
    std.debug.print("Average:   {d:.1}\n", .{average(i32, &ints)});

    // Uncommenting the line below causes a clear compile error:
    // _ = sum(bool, &[_]bool{ true, false });
}

@typeInfo returns a tagged union describing the type's structure. Matching on .int and .float confirms numeric types. @compileError halts compilation with a human-readable message that appears at the call site rather than inside the function body. @typeName returns a string form of the type, useful for constructing error messages.

Try It Yourself

Write a generic clamp function that constrains a value between a minimum and maximum, working for both integers and floats. Then extend it with a Pair struct that stores two values of potentially different types.

const std = @import("std");

fn clamp(comptime T: type, value: T, min_val: T, max_val: T) T {
    if (value < min_val) return min_val;
    if (value > max_val) return max_val;
    return value;
}

fn Pair(comptime A: type, comptime B: type) type {
    return struct {
        first: A,
        second: B,

        const Self = @This();

        pub fn swap(self: Self) Pair(B, A) {
            return .{ .first = self.second, .second = self.first };
        }
    };
}

pub fn main() void {
    std.debug.print("{}\n",   .{clamp(i32,  -5,  0, 100)});  // 0
    std.debug.print("{}\n",   .{clamp(i32,  50,  0, 100)});  // 50
    std.debug.print("{}\n",   .{clamp(i32, 150,  0, 100)});  // 100
    std.debug.print("{d}\n",  .{clamp(f32, 1.5, 0.0, 1.0)}); // 1.0

    const p = Pair(i32, []const u8){ .first = 7, .second = "zig" };
    std.debug.print("first={}, second={s}\n", .{ p.first, p.second });

    const swapped = p.swap();
    std.debug.print("first={s}, second={}\n", .{ swapped.first, swapped.second });

    // Try adding a @typeInfo guard to clamp that rejects non-numeric types
    // Try adding a format method to Pair that prints both values
}

Key Takeaways

  • Zig has no separate generics syntax — types are compile-time values, making generic programming a natural consequence of comptime
  • anytype infers the parameter type at each call site and generates a concrete specialization; constraints are implicit and errors surface inside the function
  • comptime T: type makes the type parameter explicit and nameable, enabling multi-parameter type relationships and self-documenting signatures
  • A generic struct is a comptime function that returns a type — call it with a type argument to produce a concrete struct definition
  • @typeInfo enables compile-time type introspection for enforcing constraints and branching on type structure
  • @compileError and @typeName turn implicit type failures into precise, actionable messages at the call site
  • All generic instantiation happens at compile time — the runtime binary contains only concrete, specialized code with no overhead

Pro Tip: Reach for anytype when writing small utility functions where intent is obvious from context — a print wrapper, a swap helper, a comparator. Reach for comptime T: type when the type appears more than once in the signature, when you need to reference it inside the body, or when you want to add @typeInfo constraints. Named type parameters are self-documenting and produce far better diagnostics when a caller passes the wrong type.

Next Steps

Generic code is powerful, but it needs to operate on real data. Next, you'll apply what you've learned about slices, error handling, and allocators to read and write files -- one of the most practical skills in systems programming.

Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.