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.
