Overview
Generics in Zig are nothing more than regular functions parameterized by comptime values, yet this simplicity hides a remarkable amount of expressive power. In this chapter, we turn the reflective techniques from 15 into disciplined API design patterns: structuring capability contracts, forwarding concrete types with anytype, and keeping the call sites ergonomic without sacrificing correctness.
We also cover the opposite end of the spectrum—runtime type erasure—where opaque pointers and handwritten vtables let you store heterogeneous behavior in uniform containers. These techniques complement the lookup-table generation from 16 and prepare us for the fully generic priority queue project that follows. For release notes, see v0.15.2.
Learning Goals
- Build compile-time contracts that validate user-supplied types before code generation, delivering clear diagnostics.
- Wrap arbitrary writers and strategies with
anytype, preserving zero-cost abstractions while keeping call sites tidy. See Writer.zig. - Apply
anyopaquepointers and explicit vtables to erase types safely, aligning state and handling lifetimes without undefined behavior.
Comptime contracts as interfaces
A Zig function becomes generic the moment it accepts a comptime parameter. By pairing that flexibility with capability checks—@hasDecl, @TypeOf, or even custom predicates—you can encode rich structural interfaces without heavyweight trait systems. 15 We start by seeing how a metric aggregator contract pushes errors to compile time instead of relying on runtime assertions.
Validating structural requirements
computeReport below accepts an analyzer type that must expose State, Summary, init, observe, and summarize. The validateAnalyzer helper makes these requirements explicit; forgetting a method gives a precise @compileError instead of a mysterious instantiation failure. We demonstrate the pattern with a RangeAnalyzer and a MeanVarianceAnalyzer.
const std = @import("std");
fn validateAnalyzer(comptime Analyzer: type) void {
if (!@hasDecl(Analyzer, "State"))
@compileError("Analyzer must define `pub const State`.");
const state_alias = @field(Analyzer, "State");
if (@TypeOf(state_alias) != type)
@compileError("Analyzer.State must be a type.");
if (!@hasDecl(Analyzer, "Summary"))
@compileError("Analyzer must define `pub const Summary`.");
const summary_alias = @field(Analyzer, "Summary");
if (@TypeOf(summary_alias) != type)
@compileError("Analyzer.Summary must be a type.");
if (!@hasDecl(Analyzer, "init"))
@compileError("Analyzer missing `pub fn init`.");
if (!@hasDecl(Analyzer, "observe"))
@compileError("Analyzer missing `pub fn observe`.");
if (!@hasDecl(Analyzer, "summarize"))
@compileError("Analyzer missing `pub fn summarize`.");
}
fn computeReport(comptime Analyzer: type, readings: []const f64) Analyzer.Summary {
comptime validateAnalyzer(Analyzer);
var state = Analyzer.init(readings.len);
for (readings) |value| {
Analyzer.observe(&state, value);
}
return Analyzer.summarize(state);
}
const RangeAnalyzer = struct {
pub const State = struct {
min: f64,
max: f64,
seen: usize,
};
pub const Summary = struct {
min: f64,
max: f64,
spread: f64,
};
pub fn init(_: usize) State {
return .{
.min = std.math.inf(f64),
.max = -std.math.inf(f64),
.seen = 0,
};
}
pub fn observe(state: *State, value: f64) void {
state.seen += 1;
state.min = @min(state.min, value);
state.max = @max(state.max, value);
}
pub fn summarize(state: State) Summary {
if (state.seen == 0) {
return .{ .min = 0, .max = 0, .spread = 0 };
}
return .{
.min = state.min,
.max = state.max,
.spread = state.max - state.min,
};
}
};
const MeanVarianceAnalyzer = struct {
pub const State = struct {
count: usize,
sum: f64,
sum_sq: f64,
};
pub const Summary = struct {
mean: f64,
variance: f64,
};
pub fn init(_: usize) State {
return .{ .count = 0, .sum = 0, .sum_sq = 0 };
}
pub fn observe(state: *State, value: f64) void {
state.count += 1;
state.sum += value;
state.sum_sq += value * value;
}
pub fn summarize(state: State) Summary {
if (state.count == 0) {
return .{ .mean = 0, .variance = 0 };
}
const n = @as(f64, @floatFromInt(state.count));
const mean = state.sum / n;
const variance = @max(0.0, state.sum_sq / n - mean * mean);
return .{ .mean = mean, .variance = variance };
}
};
pub fn main() !void {
const readings = [_]f64{ 21.0, 23.5, 22.1, 24.0, 22.9 };
const range = computeReport(RangeAnalyzer, readings[0..]);
const stats = computeReport(MeanVarianceAnalyzer, readings[0..]);
std.debug.print(
"Range -> min={d:.2} max={d:.2} spread={d:.2}\n",
.{ range.min, range.max, range.spread },
);
std.debug.print(
"Mean/variance -> mean={d:.2} variance={d:.3}\n",
.{ stats.mean, stats.variance },
);
}
$ zig run chapters-data/code/17__generic-apis-and-type-erasure/comptime_contract.zigRange -> min=21.00 max=24.00 spread=3.00
Mean/variance -> mean=22.70 variance=1.124The contract remains zero-cost: once validated, the analyzer methods inline as if you had written specialized code, while still surfacing readable diagnostics for downstream users.
Diagnosing capability gaps
Because validateAnalyzer centralizes the checks, you can extend the interface over time—by requiring pub const SummaryFmt = []const u8, for instance—without touching every call site. When an adopter upgrades and misses a new declaration, the compiler reports exactly which requirement is absent. This “fail fast, fail specific” strategy scales especially well for internal frameworks and prevents silent drift between modules. 37
Trade-offs and batching considerations
Keep contract predicates cheap. Anything more than a handful of @hasDecl checks or straightforward type comparisons should be factored behind an opt-in feature flag or cached in a comptime var. Heavy analysis in a widely-instantiated helper quickly balloons compile times—profile with zig build --verbose-cc if a generic takes longer than expected. 40
Under the hood: InternPool and generic instances
When computeReport is instantiated with a concrete analyzer, the compiler resolves all of the involved types and values through a shared InternPool. This structure guarantees that each unique analyzer State, Summary, and function type has a single canonical identity before code generation.
Key properties:
- Content-addressed storage: Each unique type/value is stored once, identified by an
Index. - Thread-safe:
shardsallow concurrent writes via fine-grained locking. - Dependency tracking: Maps from source hashes, Navs, and interned values to dependent analysis units.
- Special values: Pre-allocated indices for common types like
anyerror_type,type_info_type, etc.
Forwarding with wrappers
Once you trust the capabilities of a concrete type, you often want to wrap or adapt it without reifying a trait object. anytype is the perfect tool: it copies the concrete type into the wrapper’s signature, preserving monomorphized performance while allowing you to build chains of decorators. 15 The next example shows a reusable “prefixed writer” that works equally well for fixed buffers and growable lists.
A reusable prefixed writer
We fabricate two sinks: a fixed-buffer stream from the reorganized std.Io namespace and a heap-backed ArrayList wrapper with its own GenericWriter. withPrefix captures their concrete writer types via @TypeOf, returning a struct whose print method prepends a label before forwarding to the inner writer.
const std = @import("std");
fn PrefixedWriter(comptime Writer: type) type {
return struct {
inner: Writer,
prefix: []const u8,
pub fn print(self: *@This(), comptime fmt: []const u8, args: anytype) !void {
try self.inner.print("[{s}] ", .{self.prefix});
try self.inner.print(fmt, args);
}
};
}
fn withPrefix(writer: anytype, prefix: []const u8) PrefixedWriter(@TypeOf(writer)) {
return .{
.inner = writer,
.prefix = prefix,
};
}
const ListSink = struct {
allocator: std.mem.Allocator,
list: std.ArrayList(u8) = std.ArrayList(u8).empty,
const Writer = std.io.GenericWriter(*ListSink, std.mem.Allocator.Error, writeFn);
fn writeFn(self: *ListSink, chunk: []const u8) std.mem.Allocator.Error!usize {
try self.list.appendSlice(self.allocator, chunk);
return chunk.len;
}
pub fn writer(self: *ListSink) Writer {
return .{ .context = self };
}
pub fn print(self: *ListSink, comptime fmt: []const u8, args: anytype) !void {
try self.writer().print(fmt, args);
}
pub fn deinit(self: *ListSink) void {
self.list.deinit(self.allocator);
}
};
pub fn main() !void {
var stream_storage: [256]u8 = undefined;
var fixed_stream = std.Io.fixedBufferStream(&stream_storage);
var pref_stream = withPrefix(fixed_stream.writer(), "stream");
try pref_stream.print("value = {d}\n", .{42});
try pref_stream.print("tuple = {any}\n", .{.{ 1, 2, 3 }});
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var sink = ListSink{ .allocator = allocator };
defer sink.deinit();
var pref_array = withPrefix(sink.writer(), "array");
try pref_array.print("flags = {any}\n", .{.{ true, false }});
try pref_array.print("label = {s}\n", .{"generic"});
std.debug.print("Fixed buffer stream captured:\n{s}", .{fixed_stream.getWritten()});
std.debug.print("ArrayList writer captured:\n{s}", .{sink.list.items});
}
$ zig run chapters-data/code/17__generic-apis-and-type-erasure/prefixed_writer.zigFixed buffer stream captured:
[stream] value = 42
[stream] tuple = .{ 1, 2, 3 }
ArrayList writer captured:
[array] flags = .{ true, false }
[array] label = genericstd.Io.fixedBufferStream and std.io.GenericWriter were both polished in Zig 0.15.2 to emphasize explicit writer contexts, which is why we pass the allocator into ListSink.writer() each time. fixed_buffer_stream.zig
Guardrails for
Prefer anytype in helpers that merely forward calls; export public APIs with explicit comptime T: type parameters so that documentation and tooling stay honest. If a wrapper accepts anytype but inspects @TypeInfo deeply, document the expectation and consider moving the predicate into a reusable validator like we did with analyzers. That way a future refactor can upgrade the constraint without rewriting the wrapper. 37
helpers for structural contracts
When an anytype wrapper needs to understand the shape of the value it is forwarding, std.meta offers small, composable "view" functions. They are used pervasively in the standard library to implement generic helpers that adapt to arrays, slices, optionals, and unions at compile time.
Key type extraction functions:
Child(T): Extracts the child type from arrays, vectors, pointers, and optionals (see meta.zig:83-91).Elem(T): Gets the element type from memory span types (see meta.zig:102-118).sentinel(T): Returns the sentinel value, if present (see meta.zig:134-150).Tag(T): Gets the tag type from enums and unions (see meta.zig:628-634).activeTag(u): Returns the active tag of a union value (see meta.zig:651-654).
Inline costs and specialization
Each distinct concrete writer instantiates a fresh copy of the wrapper. Use this to your advantage—attach comptime-known prefixes, bake in field offsets, or gate an inline for that only triggers for tiny objects. If the wrapper might be applied to dozens of types, double-check code size with zig build-exe -femit-bin= to avoid bloating binaries. 41
Runtime type erasure with vtables
Sometimes you need to hold a heterogeneous set of strategies at runtime: logging backends, diagnostics passes, or data sinks discovered via configuration. Zig’s answer is explicit vtables containing function pointers plus *anyopaque state that you allocate yourself. The compiler stops enforcing structure, so it becomes your responsibility to maintain alignment, lifetime, and error propagation.
Typed state, erased handles
The registry below manages two text processors. Each factory allocates a strongly-typed state, casts it to *anyopaque, and stores it alongside a vtable of function pointers. Helper functions statePtr and stateConstPtr recover the original types with @alignCast, ensuring we never violate alignment requirements.
const std = @import("std");
const VTable = struct {
name: []const u8,
process: *const fn (*anyopaque, []const u8) void,
finish: *const fn (*anyopaque) anyerror!void,
};
fn statePtr(comptime T: type, ptr: *anyopaque) *T {
const aligned = @as(*align(@alignOf(T)) anyopaque, @alignCast(ptr));
return @as(*T, @ptrCast(aligned));
}
fn stateConstPtr(comptime T: type, ptr: *anyopaque) *const T {
const aligned = @as(*align(@alignOf(T)) anyopaque, @alignCast(ptr));
return @as(*const T, @ptrCast(aligned));
}
const Processor = struct {
state: *anyopaque,
vtable: *const VTable,
pub fn name(self: *const Processor) []const u8 {
return self.vtable.name;
}
pub fn process(self: *Processor, text: []const u8) void {
_ = @call(.auto, self.vtable.process, .{ self.state, text });
}
pub fn finish(self: *Processor) !void {
try @call(.auto, self.vtable.finish, .{self.state});
}
};
const CharTallyState = struct {
vowels: usize,
digits: usize,
};
fn charTallyProcess(state_ptr: *anyopaque, text: []const u8) void {
const state = statePtr(CharTallyState, state_ptr);
for (text) |byte| {
if (std.ascii.isAlphabetic(byte)) {
const lower = std.ascii.toLower(byte);
switch (lower) {
'a', 'e', 'i', 'o', 'u' => state.vowels += 1,
else => {},
}
}
if (std.ascii.isDigit(byte)) {
state.digits += 1;
}
}
}
fn charTallyFinish(state_ptr: *anyopaque) !void {
const state = stateConstPtr(CharTallyState, state_ptr);
std.debug.print(
"[{s}] vowels={d} digits={d}\n",
.{ char_tally_vtable.name, state.vowels, state.digits },
);
}
const char_tally_vtable = VTable{
.name = "char-tally",
.process = &charTallyProcess,
.finish = &charTallyFinish,
};
fn makeCharTally(allocator: std.mem.Allocator) !Processor {
const state = try allocator.create(CharTallyState);
state.* = .{ .vowels = 0, .digits = 0 };
return .{ .state = state, .vtable = &char_tally_vtable };
}
const WordStatsState = struct {
total_chars: usize,
sentences: usize,
longest_word: usize,
current_word: usize,
};
fn wordStatsProcess(state_ptr: *anyopaque, text: []const u8) void {
const state = statePtr(WordStatsState, state_ptr);
for (text) |byte| {
state.total_chars += 1;
if (byte == '.' or byte == '!' or byte == '?') {
state.sentences += 1;
}
if (std.ascii.isAlphanumeric(byte)) {
state.current_word += 1;
if (state.current_word > state.longest_word) {
state.longest_word = state.current_word;
}
} else if (state.current_word != 0) {
state.current_word = 0;
}
}
}
fn wordStatsFinish(state_ptr: *anyopaque) !void {
const state = statePtr(WordStatsState, state_ptr);
if (state.current_word > state.longest_word) {
state.longest_word = state.current_word;
}
std.debug.print(
"[{s}] chars={d} sentences={d} longest-word={d}\n",
.{ word_stats_vtable.name, state.total_chars, state.sentences, state.longest_word },
);
}
const word_stats_vtable = VTable{
.name = "word-stats",
.process = &wordStatsProcess,
.finish = &wordStatsFinish,
};
fn makeWordStats(allocator: std.mem.Allocator) !Processor {
const state = try allocator.create(WordStatsState);
state.* = .{ .total_chars = 0, .sentences = 0, .longest_word = 0, .current_word = 0 };
return .{ .state = state, .vtable = &word_stats_vtable };
}
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
var arena = std.heap.ArenaAllocator.init(gpa.allocator());
defer arena.deinit();
const allocator = arena.allocator();
var processors = [_]Processor{
try makeCharTally(allocator),
try makeWordStats(allocator),
};
const samples = [_][]const u8{
"Generic APIs feel like contracts.",
"Type erasure lets us pass handles without templating everything.",
};
for (samples) |line| {
for (&processors) |*processor| {
processor.process(line);
}
}
for (&processors) |*processor| {
try processor.finish();
}
}
$ zig run chapters-data/code/17__generic-apis-and-type-erasure/type_erasure_registry.zig[char-tally] vowels=30 digits=0
[word-stats] chars=97 sentences=2 longest-word=10Keep track of lifetimes—the arena allocator outlives the processors, so the erased pointers stay valid. Switching to a scoped allocator would require a matching destroy hook in the vtable to avoid dangling pointers. 10, Allocator.zig
Standard allocator as a vtable case study
The standard library’s std.mem.Allocator is itself a type-erased interface: every allocator implementation provides a concrete state pointer plus a vtable of function pointers. This mirrors the registry pattern above but in a form that the entire ecosystem relies on.
The Allocator type is defined in Allocator.zig:7-20 as a type-erased interface with a pointer and vtable. The vtable contains four fundamental operations:
alloc: Returns a pointer tolenbytes with the specified alignment, or null on failure (see Allocator.zig:29).resize: Attempts to expand or shrink memory in place (see Allocator.zig:48).remap: Attempts to expand or shrink memory, allowing relocation (see Allocator.zig:69).free: Frees and invalidates a region of memory (see Allocator.zig:81).
Safety notes for
anyopaque has a declared alignment of one, so every downcast must assert the true alignment with @alignCast. Skipping that assertion is illegal behavior even if the pointer happens to be properly aligned at runtime. Consider stashing the allocator and a cleanup function inside the vtable when ownership spans multiple modules.
When to graduate to modules or packages
Manual vtables shine for small, closed sets of behaviors. As soon as the surface area grows, migrate to a module-level registry that exposes constructors returning typed handles. Consumers still receive erased pointers, but the module can enforce invariants and share helper code for alignment, cleanup, and panic diagnostics. 19
Notes & Caveats
- Favor small, intention-revealing validator helpers—long
validateXfunctions are ripe for extraction into reusable comptime utilities. 15 anytypewrappers generate one instantiation per concrete type. Profile binary size when exposing them in widely-used libraries. 41- Type erasure pushes correctness to the programmer. Add assertions, logging, or debug toggles in development builds to prove that downcasts and lifetimes remain valid. 39
Exercises
- Extend
validateAnalyzerto require an optionalsummarizeErrorfunction and demonstrate custom error sets in a test. 13 - Add a
flushcapability toPrefixedWriter, detecting at comptime whether the inner writer exposes the method and adapting accordingly. meta.zig - Introduce a third processor that streams hashes into a
std.crypto.hash.sha2.Sha256context, then prints the digest in hex when finished. 52, sha2.zig
Alternatives & Edge Cases
- If compile-time validation depends on user-supplied types from other packages, add smoke tests so regressions surface before integration builds. 22
- Prefer
union(enum)with payloaded variants when only a handful of strategies exist; vtables pay off once you cross from “few” to “many.” 08 - For plug-in systems loaded from shared objects, pair erased state with explicit ABI-safe trampolines to keep portability manageable. 33