Achieving polymorphism via dynamic dispatch in Zig Unlike many languages that offer interface or virtual constructs, Zig has no built-in notion of interfaces. This reflects Zig’s commitment to simplicity and performance. That doesn’t mean polymorphism is off the table. In fact Zig has the tools to build interface-like behavior, making dynamic dispatch possible. Polymorphism in Zig: The Options Let’s backtrack a bit. There are ways to achieve polymorphism in Zig, depending on the use case: Generics and comptime dispatch - for static polymorphism based on types and functions. - for static polymorphism based on types and functions. Tagged unions - for closed sets of known types, enabling sum-type polymorphism. - for closed sets of known types, enabling sum-type polymorphism. VTable interfaces - for dynamic dispatch across heterogeneous implementations. A common motivation for interfaces is to allow uniform typing, e.g. storing multiple implementations in an array or map. Both tagged unions and vtable-based interfaces support this. On VTable Interfaces In this post we’ll focus on vtable interfaces. While I was doing the ZigJR project, I had the need for using interfaces to plug in different implementations. There had been a number of approaches developed over time to make vtable interface possible in Zig. After a deep dive into the language, I have settled on one pattern. With some finetuning, this pattern provides a clean, flexible, and reusable approach, with little to no impact on implementation types. Goals of This Interface Pattern This approach achieves: Clear separation between interface and implementation. No changes required in implementation types. Full dynamic dispatch via function pointers. Uniform type for all interface instances (enabling storage in arrays, maps, etc.). Let’s explore how it works step-by-step. Example Use Case: Loggers Let’s say we’re building a logging system that supports multiple backends: A debug logger that prints to the console. A file logger that writes to disk. And others. Each logger supports a common interface: log() and setLevel() . Note: The code below is simplified for illustrative purposes. It omits many practical considerations in favor of brevity. Debug Logger Below is the implementation of a logger logging to the std.debug output. It has its own idiosyncrasy like keeping track of the message count. pub const DbgLogger = struct { level: usize = 0, count: usize = 0; pub fn log(self: *DbgLogger, msg: []const u8) void { self.count += 1; std.debug.print("{}: [level {}] {s} ", .{self.count, self.level, msg}); } pub fn setLevel(self: *DbgLogger, level: usize) void { self.level = level; } }; File Logger Here is another logging implementation that logs to a file. It has its own init() and deinit() methods dealing with the log file. pub const FileLogger = struct { file: std.fs.File, pub fn init(path: []const u8) !FileLogger { return .{ .file = try std.fs.cwd().createFile(path, .{ .read = false }) }; } pub fn deinit(self: *FileLogger) void { self.file.close(); } pub fn log(self: *FileLogger, msg: []const u8) void { self.file.writer().print("{s} ", .{msg}) catch |err| std.debug.print("Err: {any} ", .{err}); } pub fn setLevel(self: *FileLogger, level: usize) void { self.file.writer().print("== New Level {} =={s} ", .{level}) catch |err| std.debug.print("Err: {any} ", .{err}); } }; Note: The two implementations are totally independent. They don’t know about any “interface” but they share the same “interface” method signatures. Building the VTable Interface Below is the core Logger interface type, with labeled sections for explanation. /// Logger interface pub const Logger = struct { impl: *anyopaque, // (1) pointer to the implementation v_log: *const fn(*anyopaque, []const u8) void, // (2) vtable v_setLevel: *const fn(*anyopaque, usize) void, // (2) vtable // (3) Link up the implementation pointer and vtable functions pub fn implBy(impl_obj: anytype) Logger { const delegate = LoggerDelegate(impl_obj); return .{ .impl = impl_obj, .v_log = delegate.log, .v_setLevel = delegate.setLevel, }; } // (4) Public methods of the interface pub fn log(self: Logger, msg: []const u8) void { self.v_log(self.impl, msg); } pub fn setLevel(self: Logger, level: usize) void { self.v_setLevel(self.impl, level); } }; // (5) Delegate to turn the opaque pointer back to the implementation. inline fn LoggerDelegate(impl_obj: anytype) type { const ImplType = @TypeOf(impl_obj); return struct { fn log(impl: *anyopaque, msg: []const u8) void { TPtr(ImplType, impl).log(msg); } fn setLevel(impl: *anyopaque, level: usize) void { TPtr(ImplType, impl).setLevel(level); } }; } fn TPtr(T: type, opaque_ptr: *anyopaque) T { return @as(T, @ptrCast(@alignCast(opaque_ptr))); } Using the Interface var dbg_logger = DbgLogger {}; const logger1 = Logger.implBy(&dbg_logger); logger1.log("Hello1"); logger1.log("Hello2"); logger1.setLevel(2); logger1.log("Hello3"); var file_logger = try FileLogger.init("log.txt"); defer file_logger.deinit(); const logger2 = Logger.implBy(&file_logger); logger2.log("Hello1"); logger2.setLevel(3); logger2.log("Hello2"); logger2.log("Hello3"); const loggers = [_] Logger {logger1, logger2}; for (loggers) |l| l.log("Hello to all loggers"); Both logger1 and logger2 are of type Logger , so they can be stored in arrays, passed to functions, or placed in maps, just like in strictly typed languages with first-class interfaces. How It Works Let’s review the key parts of this interface pattern: Part Role (1) impl: *anyopaque Stores the implementation as an untyped pointer. (2) function pointers The “vtable” pointers to method shims that downcast and call the real methods. (3) implBy() Connects an implementation to the interface’s untyped pointer and vtable. (4) Interface methods Public API of the interface. Call into the vtable with the opaque pointer. (5) Delegate struct Reconstructs the original type and calls its methods. This also enforces parameter checking at compile time to ensure the implementation’s functions match up to the interface’s ones. Advantages Clean separation : Implementations don’t know about the interface. : Implementations don’t know about the interface. Extensible : Adding a new implementation requires no changes to the interface. : Adding a new implementation requires no changes to the interface. Uniform type: You can store different implementations together. Trade-offs Boilerplate: must manually define each vtable method and delegate. Some indirection: Dynamic dispatch requires extra function pointer calls (minimal, but real). The upside is that all boilerplate and complexity is confined to the interface layer; implementations remain simple and pure. Closing Thoughts Though Zig doesn’t have interfaces as a language feature, you can still build your own. You get full control over abstraction, and zero runtime overhead if you want it. With vtable interfaces, you can achieve dynamic dispatch, support uniform types, and write expressive, decoupled APIs. With better tooling or codegen in the future, some of the boilerplate may even be eliminated. Until then, this approach gives you the flexibility of interfaces, in the Zig way.