Zig Generics

Generics let you write code that works with any type. Instead of writing a separate maxInt, maxFloat, and maxString function, you write one max function that accepts a type parameter and works correctly for all of them. In Zig, generics are implemented through comptime — the same system you already know.

The Problem Generics Solve

  Without generics:
  fn maxI32(a: i32, b: i32) i32 { return if (a > b) a else b; }
  fn maxF64(a: f64, b: f64) f64 { return if (a > b) a else b; }
  fn maxU8 (a: u8,  b: u8 ) u8  { return if (a > b) a else b; }
  // Identical logic, three different functions. Maintenance nightmare.

  With generics:
  fn max(comptime T: type, a: T, b: T) T {
      return if (a > b) a else b;
  }
  // One function. Works for any comparable type.

A Generic Function

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

const a = max(i32, 10, 25);    // 25
const b = max(f64, 3.14, 2.7); // 3.14
const c = max(u8,  200, 150);  // 200
  Compile time:
  max(i32, ...) → compiler generates max_i32 function
  max(f64, ...) → compiler generates max_f64 function
  max(u8,  ...) → compiler generates max_u8  function

  Runtime:
  Calls go directly to the correct specialized version.
  No overhead. No virtual dispatch.

Generic Structs — Typed Containers

Struct definitions can take type parameters, creating generic data structures:

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

        const Self = @This();

        fn push(self: *Self, item: T) !void {
            if (self.top >= 64) return error.StackFull;
            self.items[self.top] = item;
            self.top += 1;
        }

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

        fn peek(self: *Self) ?T {
            if (self.top == 0) return null;
            return self.items[self.top - 1];
        }
    };
}
  Stack(i32)   → a stack that holds i32 values
  Stack(f64)   → a stack that holds f64 values
  Stack([]u8)  → a stack that holds string slices

  Each is a completely separate type, generated at compile time.

Using a Generic Stack

const std = @import("std");

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

    while (int_stack.pop()) |val| {
        std.debug.print("{d}\n", .{val});
    }
}

Output:

30
20
10

Generic with Type Constraints

Use @typeInfo to restrict which types a generic function accepts:

fn average(comptime T: type, data: []const T) f64 {
    comptime {
        const info = @typeInfo(T);
        if (info != .Int and info != .Float) {
            @compileError("average: requires numeric type");
        }
    }

    var sum: f64 = 0;
    for (data) |val| sum += @as(f64, @floatCast(val));
    return sum / @as(f64, @floatFromInt(data.len));
}

const scores = [_]u32{ 70, 85, 90, 60, 95 };
const avg = average(u32, &scores);
std.debug.print("Average: {d:.1}\n", .{avg});  // 80.0

Generic Pair

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

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

const p = Pair([]const u8, i32){ .first = "score", .second = 100 };
std.debug.print("{s}: {d}\n", .{p.first, p.second});  // score: 100

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

@This() — Self-Referencing Inside a Generic

@This() returns the type of the enclosing struct, enum, or union. Inside a generic struct, this is the only way to refer to the generated type, since the type does not have a fixed name:

fn LinkedNode(comptime T: type) type {
    return struct {
        value: T,
        next:  ?*@This() = null,   // pointer to the same node type
    };
}

Standard Library Generics

Zig's standard library uses generics extensively:

  std.ArrayList(T)       → dynamic array of T
  std.HashMap(K, V, ...)  → hash map from K to V
  std.PriorityQueue(T, ...) → priority queue of T
  std.SinglyLinkedList(T) → linked list of T
  std.atomic.Value(T)    → atomic wrapper for T
  var list = std.ArrayList(u32).init(allocator);
  defer list.deinit();
  try list.append(1);
  try list.append(2);
  try list.append(3);
  // list.items = [1, 2, 3]

Comptime Duck Typing

Zig's generics use structural typing — if a type has the fields and methods required by the generic code, it works. No explicit interface declaration needed:

fn printLength(comptime T: type, value: T) void {
    // T must have a .len field — checked at compile time
    std.debug.print("Length: {d}\n", .{value.len});
}

const arr = [_]u8{ 1, 2, 3, 4, 5 };
const str = "hello";

printLength([5]u8, arr);   // Length: 5
printLength([]const u8, str); // Length: 5

If you pass a type that does not have .len, the compiler tells you exactly which field or method is missing. This gives you the flexibility of duck typing with the safety of compile-time verification.

Leave a Comment

Your email address will not be published. Required fields are marked *