Skip to content

Type Constraints by Use

Zig does not have a formal trait or interface system.

Zig does not have a formal trait or interface system.

Instead, generic code is constrained by the operations it performs. A type is valid if the required operations exist for that type.

Consider this function:

fn add(a: anytype, b: @TypeOf(a)) @TypeOf(a) {
    return a + b;
}

This function does not explicitly require numeric types.

Instead, it assumes the + operator is valid.

These calls work:

const x = add(3, 4);
const y = add(1.5, 2.25);

because integers and floating-point values support addition.

This call fails:

const z = add(true, false);

The compiler reports an error because booleans do not support +.

The constraint comes from use.

The compiler checks operations only after the function is instantiated with concrete types.

This style appears throughout Zig code.

Here is a generic equality function:

fn equal(a: anytype, b: @TypeOf(a)) bool {
    return a == b;
}

The only requirement is that the type supports ==.

The compiler enforces this automatically.

A more realistic example is sorting.

fn lessThan(a: anytype, b: @TypeOf(a)) bool {
    return a < b;
}

This works for ordered numeric types.

It fails for structs unless the struct defines meaningful comparison logic elsewhere.

Sometimes the requirements should be checked explicitly.

Zig provides compile-time reflection for this purpose.

This function accepts only integer types:

const std = @import("std");

fn double(value: anytype) @TypeOf(value) {
    const T = @TypeOf(value);

    const info = @typeInfo(T);

    switch (info) {
        .int => {},
        else => @compileError("expected integer type"),
    }

    return value * 2;
}

pub fn main() void {
    const x = double(21);

    std.debug.print("{d}\n", .{x});
}

The output is:

42

The line:

@typeInfo(T)

returns information about the type.

The switch checks whether the type category is .int.

If not, the compiler stops with:

expected integer type

This produces clearer diagnostics than waiting for an invalid operator later in the function.

Compile-time checks can enforce more detailed rules.

This function accepts only pointer types:

fn isNull(ptr: anytype) bool {
    const T = @TypeOf(ptr);

    switch (@typeInfo(T)) {
        .pointer => {},
        else => @compileError("expected pointer"),
    }

    return ptr == null;
}

Generic constraints may also depend on declarations.

Suppose a function requires a type to contain a method named write:

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

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

    writer.write();
}

@hasDecl checks whether a declaration exists in the type.

This resembles interface checking, but the mechanism is entirely compile-time reflection.

The standard library frequently uses this approach.

Containers, allocators, formatters, and I/O abstractions often require types to provide specific declarations or operations.

The constraints are structural:

  • does the type support this operation?
  • does this declaration exist?
  • does this function return the expected type?

No explicit inheritance hierarchy is required.

This keeps the language small while still allowing highly generic code.

Exercise 11-9. Write a generic function that accepts only floating-point types.

Exercise 11-10. Write a function that accepts only pointer types.

Exercise 11-11. Write a generic sum function for integer arrays.

Exercise 11-12. Write a compile-time check that requires a type to contain a declaration named init.