Skip to content

Interfaces by Convention

Zig has no built-in interface keyword.

Zig has no built-in interface keyword.

Instead, interfaces are formed by agreement between code that calls operations and code that provides them.

A type satisfies an interface if it contains the expected declarations with compatible behavior.

This is sometimes called structural typing, but Zig implements it through ordinary compile-time checking.

Consider a simple example.

const std = @import("std");

const ConsoleWriter = struct {
    pub fn write(self: *ConsoleWriter, bytes: []const u8) void {
        _ = self;
        std.debug.print("{s}", .{bytes});
    }
};

fn printMessage(writer: anytype) void {
    writer.write("hello\n");
}

pub fn main() void {
    var writer = ConsoleWriter{};
    printMessage(&writer);
}

The output is:

hello

printMessage accepts:

writer: anytype

The function does not care about the concrete type.

It only assumes the value provides:

write([]const u8)

If the declaration exists, the function compiles.

If not, compilation fails.

Another type may satisfy the same interface:

const BufferWriter = struct {
    buffer: []u8,
    len: usize,

    pub fn write(self: *BufferWriter, bytes: []const u8) void {
        for (bytes) |b| {
            self.buffer[self.len] = b;
            self.len += 1;
        }
    }
};

printMessage works with this type too, even though the types are unrelated.

The interface is defined entirely by convention.

A more explicit version may validate requirements at compile time.

fn printMessage(writer: anytype) void {
    const T = @TypeOf(writer);

    if (!@hasDecl(T, "write")) {
        @compileError("type must provide write()");
    }

    writer.write("hello\n");
}

This improves diagnostics.

Without the check, the compiler eventually reports that write does not exist. With the check, the error message describes the intended interface directly.

Interfaces in Zig are therefore usually documented rather than declared.

For example, an allocator interface might require:

  • alloc
  • free
  • resize

An iterator interface might require:

  • next

A formatter interface might require:

  • format

The compiler verifies usage automatically when concrete types are substituted.

This design has several consequences.

First, interface dispatch is usually static.

The compiler knows the concrete type during specialization:

fn process(writer: anytype) void

Each instantiated version operates directly on the concrete type.

There is no implicit virtual dispatch table.

Second, interfaces remain lightweight.

No inheritance graph exists. No separate interface declarations are needed. Types participate simply by providing matching operations.

Third, interfaces can be partial.

A function may require only one operation:

next()

Another function may require several:

read()
write()
flush()

The interface emerges from actual use.

Sometimes runtime polymorphism is required.

In that case, Zig programmers usually build explicit interface tables manually.

A simplified example:

const Writer = struct {
    ptr: *anyopaque,
    writeFn: *const fn (*anyopaque, []const u8) void,

    pub fn write(self: Writer, bytes: []const u8) void {
        self.writeFn(self.ptr, bytes);
    }
};

This resembles a virtual table in other languages.

The data pointer:

*anyopaque

stores an erased object pointer.

The function pointer:

writeFn

knows how to operate on it.

The standard library uses this pattern in several places when runtime dispatch is necessary.

Most generic Zig code, however, uses compile-time specialization instead of runtime polymorphism.

The language encourages simple conventions:

  • operations define the interface
  • compile-time checks enforce requirements
  • explicit runtime dispatch is built only when needed

Exercise 11-13. Write a generic function that calls next() on a type.

Exercise 11-14. Add compile-time checks requiring both read and write.

Exercise 11-15. Implement a small runtime-dispatched logger using function pointers.

Exercise 11-16. Write two unrelated structs that satisfy the same interface convention.