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 — a type-parameter list in angle brackets in Java, C++, Rust and Swift alike. 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. Everything below is pinned to Zig 0.16.0, the version the Run button uses; generics are ordinary comptime function calls rather than a separate language feature, which is why this is one of the surfaces least likely to move under you.
What You Already Know
Comptime is where the machinery under this lesson was built, and it left you with four things that everything below assumes:
- A type is a value. Not syntax, not an annotation — an ordinary value that exists during compilation and can be passed to a function. That single fact is why Zig needs no generics feature: passing one is already possible.
comptimefixes when, not what. Marking a parametercomptimesays its value must be known while compiling. It runs the same ordinary Zig — loops, branches, local mutation — just earlier, and a value that cannot be known that early is rejected rather than silently deferred.- A function can return a
type.Buffer(16)returned a struct built around the number it was given. Take atypeargument instead of a number and the same trick produces a data structure built around a type. @typeInfoturns a type into inspectable data, andinline foris how you walk that data, because its elements are only usable while comptime-known.
If any of those feel shaky, go back — this lesson does not re-derive them. What changes here is the purpose. Comptime showed you that the compiler can compute; this lesson uses that to solve one specific problem, which is that you have been writing the same function more than once.
Concretely: at the end of String Handling you wrote countMatchingBytes and countMatchingScores — five identical lines each, differing only in the element type — and Comptime collapsed them into one countMatching(comptime T: type, …). That collapse is the entire subject here, taken seriously. What happens when the thing you want to reuse is not a function but a whole data structure? When two parameters must be the same type as each other? When a type is fine for one operation and nonsense for another, and you want the compiler to say so at the call site rather than somewhere inside your body? Those are the three questions below, and this lesson is the milestone the capstone's Record Analyzer calls its aggregation engine.
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.
Three of those four calls print what you would expect. Work out what the fourth one does before reading on — the specialization the compiler generates for a string is not the one most people assume.
Predict
The helper formats its anytype parameter with {any}. What does the string call print?
const std = @import("std");
fn printValue(value: anytype) void {
std.debug.print("Value: {any}\n", .{value});
}
pub fn main() void {
printValue(42);
printValue("hello");
}The string call prints Value: { 104, 101, 108, 108, 111 } — the five byte values of hello, not the text. {any} formats structurally, and a Zig string is structurally a run of u8 bytes; printing it as characters needs the {s} specifier instead. This is the cost of anytype in miniature: one body must serve every type the compiler specializes it for, so a format choice that suits integers quietly changes meaning for strings.
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
The explicit form is the one Comptime introduced — 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.
Notice that the program above calls average with i32 only, never with f32. The next program adds that one extra call — and stops compiling. Diagnose it before you fix it.
Debug
This program computes averages over both an integer slice and a float slice. sum works for both types, but the program will not build. Commit a hypothesis about which line the compiler rejects and why, then fix average so both calls work.
const std = @import("std");
fn sum(comptime T: type, slice: []const T) 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.0, 2.0, 3.0 };
std.debug.print("Int average: {d:.1}\n", .{average(i32, &ints)});
std.debug.print("Float average: {d:.1}\n", .{average(f32, &floats)});
}Expected output:
Int average: 30.0 Float average: 2.0
The compiler rejects @floatFromInt(s) — but only in the f32 specialization. A generic body is compiled once per T, so s is an i32 in one instantiation and an f32 in the other, and @floatFromInt demands an integer argument. (slice.len is a usize regardless, so the second conversion is fine either way.) The fix is a switch (@typeInfo(T)) that picks @floatFromInt for integers and @floatCast for floats; because T is comptime-known, that branch disappears at compile time and each specialization contains only its own conversion.
Every generic signature in this lesson has written the type parameter as comptime T: type rather than plain T: type. Close the code and work out why that keyword is not optional.
Recall
Without scrolling up: the Comptime lesson established that a type in Zig is an ordinary value that exists only during compilation. Given that, why must a generic type parameter be declared comptime T: type rather than plain T: type?
comptime T: type is mandatory for two reasons that reinforce each other. A type is a compile-time-only value — there is no runtime representation for it to be passed as. And because the following parameters are declared in terms of T, the compiler cannot lay them out or pick a return type until T is resolved. Once it is, the compiler emits one concrete specialization per type argument, which is why all of this costs nothing at runtime.
Two Type Parameters at Once
Nothing limits a generic struct to a single type parameter. Pair takes two, and its swap method returns Pair(B, A) — a different generated type, with the parameters exchanged. The return type is computed by calling the generic function again during compilation:
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 });
}
clamp shows the single-type case, where T ties the value, both bounds, and the return together. Pair shows why naming types matters even more with two of them: swap's return type Pair(B, A) states the exchange in the signature, so a caller knows the field types flip without reading the body.
Assemble one of those instantiations yourself. The lines below live inside a function body, and only one arrangement compiles.
Arrange the code
Stack is a generic struct — a function taking a type and returning a type. These lines instantiate it for i32, push one value, take it back off, and print it. The pieces are shuffled. Put them in the only order that compiles and prints top=42 — then answer what the order is really testing: which lines could be reordered in a language whose generics are a declaration form, and why they cannot be here?
s.push(42);std.debug.print("top={}\n", .{top});var s = Stack(i32){};const top = s.pop().?;
The comparison at the top of this lesson — angle brackets in Java, C++, Rust and Swift, versus a function call in Zig — is worth pressing on before the build task, because the surfaces differ far more than the mechanism does.
Transfer
Rust writes a generic stack as Stack<T> and Zig writes it as Stack(T), where Stack is an ordinary function returning a type. Both produce, for each type argument, a distinct concrete type with no runtime dispatch and no boxing. Which statement names what the two genuinely share, rather than a difference of punctuation?
Try It Yourself
Reading generic code is not the same as writing a signature whose types have to relate to each other. This is a build task: a small program that reports its own pass/fail. Three generic functions and one generic type are stubbed out — one function is constrained by @typeInfo, one has to work for two different numeric types, one aggregates a slice, and the type is a running summary you instantiate over a struct of your own. Run it as-is and it fails immediately, naming the first check that did not pass. Implement each until every check passes and it prints All checks passed.
Note what the checks demand: clamp must work for both i32 and f64 from a single definition, minOf must reject a non-numeric type at compile time rather than at runtime, and Summary must seed its running minimum and maximum from the first item it sees rather than from zero. That last one has a check aimed squarely at it — a summary over the values 10, 90 and 40 whose minimum starts at zero reports a minimum of 0, a number none of those readings contained.
Build
Finish the build. Three generic functions and one generic type are stubbed out, and the checks below them fail until each one behaves. Run it as-is to see which check fails first, decide what that piece needs, then implement them all until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down. TODO 4 is the one to slow down on: two of its checks exist only to catch a summary whose running min and max start at zero instead of at the first item.
const std = @import("std");
// A tiny stand-in for an assert: print what went wrong and return an error.
// An error returned from main ends the program with a non-zero exit status.
fn check(ok: bool, comptime message: []const u8) !void {
if (!ok) {
std.debug.print("CHECK FAILED: {s}\n", .{message});
return error.CheckFailed;
}
}
// TODO 1: constrain value to the range [min_val, max_val] and return it.
// One definition must serve every numeric T — do not write it twice.
// clamp(i32, 150, 0, 100) -> 100, clamp(f64, 0.5, 0.0, 1.0) -> 0.5
fn clamp(comptime T: type, value: T, min_val: T, max_val: T) T {
_ = value;
_ = min_val;
return max_val;
}
// TODO 2: return the smaller of the two values.
// Reject non-numeric types at COMPILE time with @typeInfo and @compileError,
// the way the sum example above does — do not return a runtime error.
// minOf(i32, 7, 3) -> 3
fn minOf(comptime T: type, a: T, b: T) T {
_ = b;
return a;
}
// TODO 3: return the largest item in the slice, or null if the slice is empty.
// The return type is already ?T — reuse T rather than naming a second type.
// maxOf(i32, &.{ 4, 19, 7 }) -> 19, maxOf(i32, &.{}) -> null
fn maxOf(comptime T: type, slice: []const T) ?T {
_ = slice;
return null;
}
// TODO 4: a running summary over any comparable T — the shape the capstone's
// aggregation engine keeps per category.
// add(item) folds one item in; minimum() and maximum() return null until
// something has been added.
// Seed min and max from the FIRST item added, not from 0. A summary of
// 10, 90, 40 has a minimum of 10; one that starts its min at 0 reports 0,
// which is not a value any of those readings had.
fn Summary(comptime T: type) type {
return struct {
count: usize = 0,
min: T = undefined,
max: T = undefined,
const Self = @This();
fn add(self: *Self, item: T) void {
_ = item;
self.count += 1;
}
fn minimum(self: Self) ?T {
_ = self;
return null;
}
fn maximum(self: Self) ?T {
_ = self;
return null;
}
};
}
// A record of your own. Its value field is what gets summarised — exactly how
// the capstone summarises the value field of each parsed record.
const Reading = struct {
label: []const u8,
value: i32,
};
// --- Build checks: Do not edit below this line. ---
pub fn main() !void {
try check(clamp(i32, 150, 0, 100) == 100, "clamp(i32, 150, 0, 100) should be 100 — above the range");
try check(clamp(i32, -5, 0, 100) == 0, "clamp(i32, -5, 0, 100) should be 0 — below the range");
try check(clamp(i32, 50, 0, 100) == 50, "clamp(i32, 50, 0, 100) should be 50 — inside the range, returned unchanged");
try check(clamp(f64, 0.5, 0.0, 1.0) == 0.5, "clamp(f64, 0.5, 0.0, 1.0) should be 0.5 — one definition must serve floats too");
try check(minOf(i32, 7, 3) == 3, "minOf(i32, 7, 3) should be 3");
try check(minOf(i32, 3, 7) == 3, "minOf(i32, 3, 7) should be 3 — argument order must not matter");
try check(minOf(f64, 2.5, 2.25) == 2.25, "minOf(f64, 2.5, 2.25) should be 2.25");
const values = [_]i32{ 4, 19, 7 };
try check(maxOf(i32, &values) != null, "maxOf on a non-empty slice should return a value, not null");
try check(maxOf(i32, &values).? == 19, "maxOf(i32, &values) should be 19");
try check(maxOf(i32, &.{}) == null, "maxOf on an EMPTY slice should be null — there is no largest item");
var empty_summary = Summary(i32){};
try check(empty_summary.minimum() == null, "a Summary with nothing added should report null, not a number");
try check(empty_summary.maximum() == null, "the same for maximum — there is no largest of nothing");
const readings = [_]Reading{
.{ .label = "cpu", .value = 10 },
.{ .label = "mem", .value = 90 },
.{ .label = "net", .value = 40 },
};
var summary = Summary(i32){};
for (readings) |r| summary.add(r.value);
try check(summary.minimum().? == 10, "min over 10, 90, 40 must be 10 — a min seeded from 0 reports 0, which no reading held");
try check(summary.maximum().? == 90, "max over 10, 90, 40 must be 90");
var negatives = Summary(i32){};
negatives.add(-40);
negatives.add(-5);
try check(negatives.maximum().? == -5, "max over -40 and -5 must be -5 — a max seeded from 0 reports 0, which is larger than either");
var floats = Summary(f64){};
floats.add(2.5);
floats.add(1.5);
try check(floats.minimum().? == 1.5, "one Summary definition must serve f64 as well as i32");
std.debug.print("All checks passed.\n", .{});
std.debug.print("clamp: {} {d:.1}\n", .{ clamp(i32, 150, 0, 100), clamp(f64, 0.5, 0.0, 1.0) });
std.debug.print("minOf: {}\n", .{minOf(i32, 7, 3)});
std.debug.print("maxOf: {?}\n", .{maxOf(i32, &values)});
std.debug.print("summary: {?} to {?}\n", .{ summary.minimum(), summary.maximum() });
std.debug.print("Summary(i32) is Summary(f64): {}\n", .{Summary(i32) == Summary(f64)});
}Expected output:
All checks passed. clamp: 100 0.5 minOf: 3 maxOf: 19 summary: 10 to 90 Summary(i32) is Summary(f64): false
Once it passes, try two variations and predict each before running:
- Mismatch the bounds. Change the
clamp(f64, 0.5, 0.0, 1.0)check call toclamp(f64, 0.5, 0, 1.0), passing an integer literal formin_val. Predict whether this compiles before running. It still compiles:0is acomptime_intand coerces tof64without loss, so the check passes. Now declarevar lo: u64 = 0;and tryclamp(f64, 0.5, lo, 1.0)instead — that one fails to compile witherror: expected type 'f64', found 'u64', because an integer only coerces to a float when every one of its values is exactly representable there, andu64exceeds whatf64's 53-bit significand can hold. (Ani32would still coerce, for the same reason: it fits.) That is the signature enforcing the relationship for you. - Ask for a type the guard rejects. Add
_ = minOf(bool, true, false);tomain. Predict what you get before running. Not a runtime failure and not a wrong answer, but a build failure carrying your own message from@compileError— reported at the line that made the bad call, not deep insideminOf. Delete the@typeInfoguard and the same call fails anyway, but with a message about the<operator inside the function body, which tells the caller far less.
Capstone milestone
Milestone — the aggregation engine. The capstone's Record Analyzer keeps one running summary per category and holds them in a std.StringHashMap(Stats) — a generic type instantiated with a type you supply, exactly the Summary(i32) move you just made. Its summary tracks a min and a max, which is where the empty case and the seeding decision bite. Confirm you can write a generic container of your own and represent 'nothing to summarise' honestly rather than with a number.
Hint: You don't need the full engine yet — the Summary you just built in TODO 4 is the increment, and it is the capstone's Stats in miniature. In the capstone, aggregate builds a std.StringHashMap(Stats): the container is generic and Stats is the type argument, so the same reasoning that makes Summary(i32) and Summary(f64) separate types applies to it. And Stats.init seeds min and max from the first record's value exactly as your add does on its first item — seed a min from 0 instead and a category whose values are all positive reports a minimum of 0, a number no record contained, which is the bug the 10, 90, 40 check catches. Returning an optional before anything has been added is the same decision as maxOf returning null on an empty slice.
- Wrote a generic container of your own — a function taking a type and returning a type — and instantiated it more than once, and can say why each instantiation is a distinct type from the others
- Returned an optional from an aggregate to represent 'no items', rather than a sentinel value that a real input could also produce
- Seeded a running min and max from the first item rather than from zero or another fixed starting value
- Constrained a type parameter with @typeInfo and @compileError so a bad call fails at the call site instead of inside the body
Key Takeaways
- Zig has no separate generics syntax — types are compile-time values, making generic programming a natural consequence of
comptime anytypeinfers the parameter type at each call site and generates a concrete specialization; constraints are implicit and errors surface inside the functioncomptime T: typemakes the type parameter explicit and nameable, enabling multi-parameter type relationships and self-documenting signatures- A generic struct is a
comptimefunction that returns atype— call it with a type argument to produce a concrete struct definition @typeInfoenables compile-time type introspection for enforcing constraints and branching on type structure@compileErrorand@typeNameturn 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
anytypewhen writing small utility functions where intent is obvious from context — a print wrapper, a swap helper, a comparator. Reach forcomptime T: typewhen the type appears more than once in the signature, when you need to reference it inside the body, or when you want to add@typeInfoconstraints. 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.
Ready to try it? Continue in your learning studio to practice these ideas, save your progress, and experiment with code.