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. C++ compilers reach for a table of function pointers to implement virtual methods too, but there the compiler builds and wires it for you; in Zig you declare the table, populate it, and pass it yourself, with no compiler magic involved.
Understanding both patterns makes you a better Zig programmer and a better systems programmer in general.
What You'll Learn
By the end of this lesson you will build an interface object by hand — a struct of function pointers beside a type-erased data pointer — and write two unrelated types that satisfy it, so that one function can drive either without knowing which it has. You will also meet the failure mode that comes with building it yourself: a vtable wired to the wrong type compiles perfectly and corrupts data at run time.
What You Already Know
You already have every piece this pattern is built from: Pointers And References introduced the function-pointer type *const fn (…) …, the type-erased pointer *anyopaque that any *T coerces to, and the @ptrCast(@alignCast(ctx)) pair that recovers the concrete type on the way back in — including the reason that recovery is a promise you keep rather than a check the compiler performs. Its section Pointers to Functions and to Unknown Types is where all four were taught; if any of them feel unfamiliar, read that section before the vtable fence below, because the pattern is nothing more than those pieces assembled. From Enums and Tagged Unions you bring the other half: a union(enum) switched exhaustively, which is the closed alternative this lesson weighs the open one against.
This is an optional extension, not a capstone milestone — the record analyzer needs neither pattern. What the lesson is for is reading the standard library, where std.mem.Allocator and std.Io.Writer are exactly this construction, and knowing which of the two tools a design calls for. Everything below is pinned to Zig 0.16.0, the version the Run button uses; the vtable pattern itself is just structs and function pointers and does not move between releases, but the standard-library interfaces named above have been reshaped more than once, so check their current fields before copying a size from here.
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. The recovered pointer plays the role C++'s implicit this plays in a virtual method, with one structural difference worth holding on to: a C++ polymorphic object carries its table pointer inside itself, at offset zero, so a pointer to it is one pointer wide. Zig keeps the two separate — the data pointer and the table pointer travel side by side in the interface value — so the concrete type never has to be modified to participate, and a type you do not control can still implement your interface.
Notice that BufferWriter.vtable is a comptime constant declared on the struct itself. Function pointer tables are stored in static memory. Before taking that on faith, predict what two separate instances of one implementation actually share.
Predict
Two Dog values each produce an interface object. Predict whether their vtable pointers compare equal, and how wide the interface struct is on a 64-bit target.
const std = @import("std");
const VT = struct {
speak: *const fn (ctx: *anyopaque) void,
};
const Iface = struct {
ctx: *anyopaque,
vtable: *const VT,
};
const Dog = struct {
name: []const u8,
fn speak(ctx: *anyopaque) void {
const self: *Dog = @ptrCast(@alignCast(ctx));
std.debug.print("{s} says woof\n", .{self.name});
}
const vtable = VT{ .speak = speak };
fn iface(self: *Dog) Iface {
return .{ .ctx = self, .vtable = &vtable };
}
};
pub fn main() void {
var a = Dog{ .name = "Rex" };
var b = Dog{ .name = "Bo" };
const ia = a.iface();
const ib = b.iface();
ia.vtable.speak(ia.ctx);
ib.vtable.speak(ib.ctx);
std.debug.print("same vtable pointer: {}\n", .{ia.vtable == ib.vtable});
std.debug.print("sizeOf Iface = {d}\n", .{@sizeOf(Iface)});
}The pointers compare equal and @sizeOf(Iface) is 16. const vtable is a declaration on the type, not a field on each value, so one table lives in static memory and every instance points at it. The interface object holds only a data pointer plus a table pointer — 8 + 8 bytes on a 64-bit target — and that stays 16 regardless of how many methods the vtable contains. One table per type, shared across all instances, is what keeps interface objects small.
The cast is a promise, not a check
@ptrCast(@alignCast(ctx)) recovers the concrete type inside each implementation function, and it is worth being precise about what that recovery is: the compiler takes your word for it. The context pointer arrived as *anyopaque, which by construction has forgotten what it points at, so there is nothing left for the compiler to compare your claim against. It emits the cast and moves on. That makes the vtable pattern safe exactly as long as every interface object pairs a context pointer with the vtable belonging to that context's type — and nothing in the type system enforces the pairing, because both halves are just pointers.
Which means the pairing is a thing you can get wrong, silently. Diagnose this one before changing anything.
Debug
Two types implement the same Counter interface. Units adds up the amounts it is given; Calls ignores the amounts and counts how many times it was called. The program records two amounts through Units and makes three calls through Calls, so the totals should be 15 and 3. It compiles, runs, and exits cleanly — and one of the two numbers is impossible. Commit a hypothesis about which object each vtable's function is actually reading and writing, then fix it so the program prints 'calls times = 3'.
const std = @import("std");
const CounterVTable = struct {
record: *const fn (ctx: *anyopaque, amount: u32) void,
};
const Counter = struct {
ctx: *anyopaque,
vtable: *const CounterVTable,
fn record(self: Counter, amount: u32) void {
self.vtable.record(self.ctx, amount);
}
};
// Counts how many units were recorded.
const Units = struct {
total: u32 = 0,
fn record(ctx: *anyopaque, amount: u32) void {
const self: *Units = @ptrCast(@alignCast(ctx));
self.total += amount;
}
const vtable = CounterVTable{ .record = record };
fn counter(self: *Units) Counter {
return .{ .ctx = self, .vtable = &vtable };
}
};
// Counts how many times record was called, ignoring the amount.
const Calls = struct {
times: u32 = 0,
fn record(ctx: *anyopaque, amount: u32) void {
_ = amount;
const self: *Calls = @ptrCast(@alignCast(ctx));
self.times += 1;
}
const vtable = CounterVTable{ .record = record };
fn counter(self: *Calls) Counter {
return .{ .ctx = self, .vtable = &Units.vtable };
}
};
pub fn main() void {
var units = Units{};
var calls = Calls{};
const u = units.counter();
const c = calls.counter();
u.record(10);
u.record(5);
c.record(10);
c.record(5);
c.record(1);
std.debug.print("units total = {d}\n", .{units.total});
std.debug.print("calls times = {d}\n", .{calls.times});
}
Expected output:
units total = 15 calls times = 3
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.
The two allocators are not interchangeable choices dressed up as one, and the fence pairs each with the cleanup it requires. FixedBufferAllocator hands out slices carved from the 256-byte stack array above it, so it allocates nothing from the operating system and has no teardown call at all — the defer fba_alloc.free(msg1) is there for form, since the whole arena vanishes when buf goes out of scope. DebugAllocator really does own heap memory, which is why it needs both the defer _ = gpa.deinit() that releases and audits it and the defer gpa_alloc.free(msg2) that returns the message first; deinit reports a leak if anything is still outstanding when it runs, which is precisely the check the fixed-buffer version cannot give you. The point of showing both is that buildMessage is unchanged between them: the strategy, the lifetime and the leak-checking all differ, and the code that allocates does not.
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
Before building one, assemble the four statements that bring an interface object into existence. Only one arrangement compiles.
Arrange the code
These lines are the body of a function that creates a concrete Tag, wraps it in an Iface interface object, and calls through it. Reorder them so the program prints its tag — then answer what the order is really testing: what does the interface object actually store about the Tag, and why does that decide which line has to come first?
const iface = Iface{ .ctx = &tag, .vtable = &Tag.vtable };fn show() void {var tag = Tag{ .name = "alpha" };}iface.emit();
Now build the interface yourself. This is a build task: a small program that reports its own pass/fail. Three pieces are stubbed out across two different implementations of one Writer interface — one that counts bytes and one that copies them into a fixed buffer. 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.
The payoff is the writeTwice function at the bottom: one function, driving either implementation, knowing neither. There is no assert to import — the starter defines its own check helper that prints the failure and returns an error.
Build
Finish the build. Three pieces are stubbed out and the checks below them fail until each does its job. Run it as-is to see which check fails first, then implement all three 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. Note that TODO 3 is not the same shape as TODO 1: a writer with a fixed buffer cannot always accept everything it is offered.
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;
}
}
const WriterVTable = struct {
write: *const fn (ctx: *anyopaque, bytes: []const u8) anyerror!usize,
};
const Writer = struct {
ctx: *anyopaque,
vtable: *const WriterVTable,
fn write(self: Writer, bytes: []const u8) anyerror!usize {
return self.vtable.write(self.ctx, bytes);
}
};
// Implementation 1: counts bytes without storing them.
const CountingWriter = struct {
written: usize = 0,
// TODO 1: recover the concrete type from the erased context pointer, add
// the byte count to self.written, and report how many bytes were taken.
// A writer that accepts everything returns bytes.len.
fn write(ctx: *anyopaque, bytes: []const u8) anyerror!usize {
_ = ctx;
_ = bytes;
return 0;
}
const vtable = WriterVTable{ .write = write };
// TODO 2: build the interface object and return it. Pair THIS instance's
// address with THIS type's vtable — both halves are just pointers, so
// getting either one wrong still compiles.
fn writer(self: *CountingWriter) Writer {
_ = self;
return .{ .ctx = &placeholder, .vtable = &vtable };
}
var placeholder: CountingWriter = .{};
};
// Implementation 2: copies into a fixed buffer and stops when it is full.
const BufferWriter = struct {
buf: []u8,
pos: usize = 0,
// TODO 3: copy as many bytes as still fit, advance pos, and return the
// number actually copied — which is NOT always bytes.len.
// A 4-byte buffer given 10 bytes takes 4 and reports 4.
fn write(ctx: *anyopaque, bytes: []const u8) anyerror!usize {
_ = ctx;
return bytes.len;
}
const vtable = WriterVTable{ .write = write };
fn writer(self: *BufferWriter) Writer {
return .{ .ctx = self, .vtable = &vtable };
}
};
// --- Build checks: Do not edit below this line. ---
// One function, driving either implementation, knowing neither.
fn writeTwice(w: Writer, a: []const u8, b: []const u8) !usize {
return try w.write(a) + try w.write(b);
}
pub fn main() !void {
var counter = CountingWriter{};
const cw = counter.writer();
const taken = try writeTwice(cw, "hello, ", "vtable!");
try check(taken == 14, "a counting writer accepts every byte, so 7 + 7 = 14");
try check(counter.written == 14, "the counting writer should have accumulated 14 bytes in its own field");
var backing = [_]u8{0} ** 8;
var buffered = BufferWriter{ .buf = &backing };
const bw = buffered.writer();
const stored = try writeTwice(bw, "hello, ", "vtable!");
try check(stored == 8, "an 8-byte buffer can only take 8 of the 14 bytes offered");
try check(buffered.pos == 8, "pos should stop at the buffer size, not run past it");
try check(std.mem.eql(u8, backing[0..8], "hello, v"), "the buffer should hold the first 8 bytes in order");
try check(counter.written == 14, "writing through the buffered writer must not touch the counting writer");
var second = CountingWriter{};
const sw = second.writer();
_ = try sw.write("abc");
try check(second.written == 3, "a fresh instance starts at zero and counts only its own bytes");
try check(counter.written == 14, "two instances of one type must not share state");
try check(cw.vtable == sw.vtable, "two instances of one type DO share one vtable");
std.debug.print("All checks passed.\n", .{});
std.debug.print("counted: {d}\n", .{counter.written});
std.debug.print("buffered: {s}\n", .{backing[0..buffered.pos]});
}
Expected output:
All checks passed. counted: 14 buffered: hello, v
Once it passes, try two variations and predict each before running:
- Hand out the other type's vtable. In
CountingWriter.writer, change.vtable = &vtableto.vtable = &BufferWriter.vtable. Predict what happens before running. It compiles — both are a*const WriterVTable, so nothing in the type system objects — and then dies at run time withGeneral protection exception, pointing at the dispatch linereturn self.vtable.write(self.ctx, bytes);. The cause is thatBufferWriter.writecasts the context to a*BufferWriterand reads abufslice out of memory that actually holds aCountingWriter, so it follows a pointer assembled from whatever bytes were there. This is the debug block's bug with a less forgiving layout: same mistake, louder failure, and still nothing caught at compile time. - Make the buffered writer claim everything. In
BufferWriter.write, change the finalreturn n;toreturn bytes.len;. Predict which check fails before running. It compiles and runs, and fails on the check thatstored == 8, reporting that 14 bytes were stored in an 8-byte buffer. The buffer contents andposare still correct — only the report is wrong. A writer that overstates what it accepted corrupts the caller's accounting rather than its own, which is why the return value is part of the contract and not a convenience.
Two questions to close, one reaching back and one crossing languages.
Recall
No notes. This lesson opened by weighing vtables against tagged unions, and the case for tagged unions rests on a guarantee you met in Enums and Tagged Unions, fourteen orders back: what the compiler does when you switch on a union(enum) and a variant is not handled. What is that guarantee, and what happens to it when you switch to the vtable pattern?
Transfer
A Zig interface object like the one built in this lesson is a struct holding two pointers and nothing else: the erased data pointer and a pointer to a table of functions, which this lesson measured at 16 bytes. Interfaces that deliberately carry more state are larger, so treat 16 bytes as the floor for a two-pointer interface rather than a rule about every interface. Rust's dyn Trait object is also two pointers wide — the data pointer and a vtable pointer — and Rust builds the table for you rather than having you declare it. Which statement names what the two designs genuinely share, rather than a surface resemblance?
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 idea C++ implements for virtual dispatch, but built and passed by you rather than by the compiler, and kept beside the object rather than embedded in it - Each vtable is a comptime constant shared across all instances of a type; storing a pointer to it costs one pointer no matter how many methods it holds, so a data-pointer-plus-vtable-pointer interface like the one above (or
std.mem.Allocator) 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 the table's contribution at one pointer no matter how large the vtable grows, so an interface carrying nothing but a context pointer and a table pointer stays 16 bytes forever — which is critical when you store interfaces in arrays, pass them through channels, or build heterogeneous collections. Interfaces that deliberately carry more do exist:std.Io.Writeris 32 bytes because it keeps a buffer slice and a write index alongside its vtable pointer. The rule is about the table, not about a hard ceiling on the struct.
Next Steps
Vtables are how Zig talks to itself across type boundaries. The next extension crosses a bigger boundary: calling C directly from Zig, importing headers, converting between C strings and slices, and linking existing C libraries into your builds.