Skip to content

interfaces

nate
Jul 8, 20262 min read1 read

interfaces

written against zig 0.15.

zig has no interfaces or traits. use comptime patterns instead.

anytype with validation

accept anytype and validate at comptime:

fn process(db: anytype) !void {
    const T = @TypeOf(db);
    if (!@hasDecl(T, "exec")) {
        @compileError("db must have exec method");
    }
    try db.exec("SELECT 1", .{});
}

this is the simplest approach - compiler verifies the type has required methods.

type-returning functions

for generic wrappers, return a type that delegates to an implementation:

pub fn DatabaseInterface(comptime Impl: type) type {
    return struct {
        impl: Impl,

        const Self = @This();

        pub fn exec(self: Self, sql: []const u8, args: anytype) !void {
            return self.impl.exec(sql, args);
        }
    };
}

useful when you want a consistent outer API regardless of implementation.

vtables for runtime dispatch

when you need runtime polymorphism (rare in zig):

pub const Database = struct {
    ptr: *anyopaque,
    vtable: *const VTable,

    const VTable = struct {
        exec: *const fn (*anyopaque, []const u8) anyerror!void,
    };

    pub fn exec(self: Database, sql: []const u8) !void {
        return self.vtable.exec(self.ptr, sql);
    }
};

this is the std.io.Writer pattern. more boilerplate, but allows swapping implementations at runtime.

comptime-parameterized adapter

when you need a struct that bridges between a library's callback interface and a user's handler type, return a type parameterized on the handler:

fn WsHandler(comptime H: type) type {
    return struct {
        allocator: Allocator,
        handler: *H,
        client_state: *FirehoseClient,

        const Self = @This();

        pub fn serverMessage(self: *Self, data: []const u8) !void {
            var arena = std.heap.ArenaAllocator.init(self.allocator);
            defer arena.deinit();

            const event = decodeFrame(arena.allocator(), data) catch return;
            self.handler.onEvent(event);
        }

        pub fn close(_: *Self) void {}
    };
}

this adapts the websocket library's serverMessage callback to our firehose handler's onEvent interface. the comptime parameter H is the user's handler type — the compiler generates a specialized struct for each handler type used. no vtables, no allocation, full inlining.

see: zat/firehose.zig (zat is a library of atproto building blocks for zig)

practical: module boundary pattern

for most apps, just separate interface documentation from implementation:

db/
  mod.zig       -- re-exports, expected methods documented in comments
  sqlite.zig    -- sqlite implementation

the "interface" is implicit - documented expectations in mod.zig, verified by usage at compile time.

see: comptime, database

Did you enjoy this article?

Recommend it — Standard Reader surfaces well-loved writing to more readers across the network.

Across the AtmosphereDiscussions