TL;DR
Implement Zig interfaces using vtables — use tagged unions for closed dispatch and function pointer structs for open, runtime polymorphism.
Key concepts
- zig interfaces
- zig vtable
- zig polymorphism
- zig dynamic dispatch
- zig function pointers
Interfaces and Vtables
Zig has no interface keyword. It has no trait system, no abstract base class, and no virtual methods in the language spec. Yet Zig programs regularly achieve the same goals: writing code that works against a contract without knowing the concrete type behind it at compile time.
The language gives you two sharp tools. Tagged unions let you define a closed set of types and dispatch on them with a switch — everything is known at compile time, and the compiler enforces exhaustiveness. Vtables — a struct of function pointers paired with a type-erased data pointer — let you write code that works against an open set of types, resolved at runtime. This is the same mechanism C++ uses under the hood for virtual methods, exposed here without any compiler magic.
Understanding both patterns makes you a better Zig programmer and a better systems programmer in general.
Tagged Unions for Closed Polymorphism
When the set of types you need to handle is fixed and known at compile time, a tagged union is the right tool. The compiler guarantees that your switch covers every case, and adding a new variant forces you to update every dispatch site — a correctness guarantee you cannot get from a runtime interface.
const std = @import("std");
const Shape = union(enum) {
circle: f64,
rectangle: struct { width: f64, height: f64 },
triangle: struct { base: f64, height: f64 },
};
fn area(shape: Shape) f64 {
return switch (shape) {
.circle => |r| std.math.pi * r * r,
.rectangle => |r| r.width * r.height,
.triangle => |t| 0.5 * t.base * t.height,
};
}
pub fn main() void {
const shapes = [_]Shape{
.{ .circle = 3.0 },
.{ .rectangle = .{ .width = 4.0, .height = 5.0 } },
.{ .triangle = .{ .base = 6.0, .height = 8.0 } },
};
for (shapes) |shape| {
std.debug.print("area = {d:.2}\n", .{area(shape)});
}
}
If you add a .hexagon variant and forget to handle it in area, the compiler produces an error — not at runtime, at compile time. This is the core advantage of tagged unions for polymorphism: correctness is enforced statically, with zero runtime overhead beyond the tag check itself.
Vtables for Open Polymorphism
When the set of implementing types is open — when a library consumer should be able to plug in their own type — tagged unions break down because you cannot enumerate variants you haven't seen yet. The classic systems programming solution is a vtable: a struct of function pointers describing what an interface can do, paired with a raw pointer to the concrete data.
const std = @import("std");
// The vtable describes the interface contract as a struct of function pointers
const WriterVTable = struct {
write: *const fn (ctx: *anyopaque, bytes: []const u8) anyerror!usize,
};
// A type-erased "interface object" — data pointer + vtable pointer
const Writer = struct {
ctx: *anyopaque,
vtable: *const WriterVTable,
fn write(self: Writer, bytes: []const u8) anyerror!usize {
return self.vtable.write(self.ctx, bytes);
}
};
// A concrete implementation: writes to a fixed buffer
const BufferWriter = struct {
buf: []u8,
pos: usize = 0,
fn write(ctx: *anyopaque, bytes: []const u8) anyerror!usize {
const self: *BufferWriter = @ptrCast(@alignCast(ctx));
const n = @min(self.buf.len - self.pos, bytes.len);
@memcpy(self.buf[self.pos .. self.pos + n], bytes[0..n]);
self.pos += n;
return n;
}
// One vtable instance shared across all BufferWriter objects
const vtable = WriterVTable{ .write = write };
fn writer(self: *BufferWriter) Writer {
return .{ .ctx = self, .vtable = &vtable };
}
};
pub fn main() !void {
var backing = [_]u8{0} ** 64;
var bw = BufferWriter{ .buf = &backing };
const w = bw.writer();
_ = try w.write("hello, ");
_ = try w.write("vtable!");
std.debug.print("buffer: {s}\n", .{backing[0..bw.pos]});
}
The key casts are @ptrCast and @alignCast. The Writer stores *anyopaque — a type-erased pointer — and each implementation recovers the concrete type inside its function body. This is exactly what C++ does with the implicit this pointer in virtual dispatch, made explicit here.
Notice that BufferWriter.vtable is a comptime constant declared on the struct itself. Function pointer tables are stored in static memory — one table per type, shared across all instances. This keeps interface objects small: each Writer is exactly two pointers wide regardless of how many methods the vtable contains.
How the Standard Library Uses This Pattern
Zig's standard library uses the vtable pattern extensively. std.mem.Allocator, std.Io.Writer, and std.Io.Reader are all interface objects backed by vtables. Any allocator — FixedBufferAllocator, ArenaAllocator, DebugAllocator — implements the same vtable contract and can be passed anywhere std.mem.Allocator is accepted.
const std = @import("std");
// A function that works with any allocator through the vtable interface
fn buildMessage(alloc: std.mem.Allocator, name: []const u8) ![]u8 {
return std.fmt.allocPrint(alloc, "Hello, {s}!", .{name});
}
pub fn main() !void {
// FixedBufferAllocator satisfies the std.mem.Allocator interface
var buf: [256]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buf);
const fba_alloc = fba.allocator();
const msg1 = try buildMessage(fba_alloc, "FixedBuffer");
defer fba_alloc.free(msg1);
std.debug.print("{s}\n", .{msg1});
// DebugAllocator satisfies the same interface — different vtable
var gpa: std.heap.DebugAllocator(.{}) = .init;
defer _ = gpa.deinit();
const gpa_alloc = gpa.allocator();
const msg2 = try buildMessage(gpa_alloc, "DebugAllocator");
defer gpa_alloc.free(msg2);
std.debug.print("{s}\n", .{msg2});
}
buildMessage has no knowledge of which allocator it receives — it calls through the vtable the same way every time. This is the power of the pattern: the caller and the implementation are fully decoupled.
Comptime Duck Typing
For cases where all callers are known at compile time, Zig offers a third approach: pass any type as a comptime parameter and use its declarations directly. No vtable, no *anyopaque, no runtime cost. The compiler generates a specialized copy for each concrete type, similar to C++ templates or Rust generics.
This is how std.sort.pdq accepts a custom comparison function — the comparator type is a comptime parameter, and the call is inlined at compile time. Use vtables when you need runtime flexibility; use comptime duck typing when the caller is fixed.
Try It Yourself
Extend the vtable example to support a flush operation. Implement a NullWriter that silently discards all bytes — useful as a safe default or for testing code paths without side effects.
const std = @import("std");
const WriterVTable = struct {
write: *const fn (ctx: *anyopaque, bytes: []const u8) anyerror!usize,
flush: *const fn (ctx: *anyopaque) anyerror!void,
};
const Writer = struct {
ctx: *anyopaque,
vtable: *const WriterVTable,
fn write(self: Writer, bytes: []const u8) anyerror!usize {
return self.vtable.write(self.ctx, bytes);
}
fn flush(self: Writer) anyerror!void {
return self.vtable.flush(self.ctx);
}
};
// NullWriter discards everything — no backing storage needed
const NullWriter = struct {
fn write(_: *anyopaque, bytes: []const u8) anyerror!usize {
return bytes.len; // pretend we wrote it all
}
fn flush(_: *anyopaque) anyerror!void {}
const vtable = WriterVTable{ .write = write, .flush = flush };
var _sentinel: u8 = 0;
fn writer() Writer {
return .{ .ctx = &_sentinel, .vtable = &vtable };
}
};
fn writeReport(w: Writer, value: u32) !void {
var tmp: [32]u8 = undefined;
const s = try std.fmt.bufPrint(&tmp, "result: {d}\n", .{value});
_ = try w.write(s);
try w.flush();
}
pub fn main() !void {
std.debug.print("--- NullWriter (silent) ---\n", .{});
const nw = NullWriter.writer();
try writeReport(nw, 42);
std.debug.print("done (nothing printed by writer)\n", .{});
}
Try adding a CountingWriter that tracks total bytes written without storing them. Both types can be passed to writeReport without any change to that function.
Key Takeaways
- Zig has no
interfacekeyword; polymorphism is achieved explicitly through tagged unions and vtables - Tagged unions provide closed, exhaustive, compile-time-checked polymorphism — ideal when the full set of types is known in advance
- Vtables are structs of function pointers paired with
*anyopaquecontext — the same mechanism C++ uses for virtual dispatch, made explicit and transparent - Each vtable is a comptime constant shared across all instances of a type; an interface object is exactly two pointers wide
@ptrCast(@alignCast(ctx))recovers the concrete type inside implementation functions — safe as long as the vtable is always constructed from the matching typestd.mem.Allocator,std.Io.Writer, andstd.Io.Readerare all vtable interfaces you can implement for your own types- For callers known at compile time, prefer comptime duck typing for zero runtime cost and full inlining
Pro Tip: Store the vtable as
*const WriterVTable— a pointer to a comptime constant — rather than embedding the struct inline in your interface object. Embedding the vtable would grow the interface size with every new method you add. Storing a pointer keeps every interface object exactly two pointers wide no matter how large the vtable grows, which is critical when you store interfaces in arrays, pass them through channels, or build heterogeneous collections.
Next Steps
Vtables let you dispatch at runtime, but sometimes raw performance matters more than flexibility. Next, you'll explore Zig's first-class SIMD support, which lets you write data-parallel code that compiles directly to CPU vector instructions.
Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.