zig-tipos-genericos-comptime

Generics in Zig: types as values and comptime

  • 5 min

A generic type in Zig is a type built at compile time from other types. If you come from C++, Java, or TypeScript, you probably think of the famous angle brackets <T>; Zig approaches it differently.

In other languages, forms like List<String>, Vector<int>, or Map<String, User> appear to request a specialized version of a type.

In Zig, there is no generic syntax. This is possible because in Zig types are values available during compilation.

Just as you can pass the number 5 to a function, you can pass the type i32 to a function. And just as a function can return 42, it can return a struct.

Functions that return types

In Zig, a generic data structure (like a List) is simply a function that takes a type as a parameter (comptime) and returns a struct definition.

Let’s see how we would create our own generic “Box”:

// A function that accepts a type 'T' and returns a 'type' type
fn Box(comptime T: type) type {
    return struct {
        value: T,
        
        // We can put methods inside
        pub fn get(self: @This()) T {
            return self.value;
        }
    };
}

pub fn main() void {
    // Call the function with i32 -> Returns a new type
    const BoxOfIntegers = Box(i32);
    
    // Call the function with bool -> Returns ANOTHER new type
    const BoxOfBooleans = Box(bool);

    // Use the types
    const c1 = BoxOfIntegers{ .value = 100 };
    const c2 = BoxOfBooleans{ .value = true };
    _ = .{ c1, c2 };
}
Copied!

This is how we create generic types without additional syntax: we use comptime and a function that returns type.

By convention, functions that return types are written in PascalCase (like Box, ArrayList, HashMap), so they look like normal types when used.

Generic functions

The same applies to functions that operate on different types. We simply pass the type as an argument.

Suppose we want a max function that works for any numeric type.

fn maximum(comptime T: type, a: T, b: T) T {
    if (a > b) return a;
    return b;
}

pub fn main() void {
    const x = maximum(i32, 10, 20);
    const y = maximum(f32, 5.5, 1.2);
}
Copied!

Type inference (anytype)

Writing the type explicitly maximum(i32, ...) can be verbose. Zig allows “Duck Typing” at compile time using the keyword anytype.

If you use anytype, the parameter type is inferred on each call. In this example, a and b must support comparison and be compatible with the return type.

// 'a' and 'b' can be anything
fn maximum(a: anytype, b: anytype) @TypeOf(a) {
    if (a > b) return a;
    return b;
}

pub fn main() void {
    // Zig infers it's i32
    const x = maximum(10, 20); 
}
Copied!

Monomorphization

Zig specializes generic instances through monomorphization.

When you call Box(i32) and Box(bool), the compiler produces two distinct types. The functions used are specialized for each required combination; declarations that are never discovered do not need to emit code.

  • Advantage: the specialized code knows the concrete type and does not need dynamic dispatch for that reason.
  • Disadvantage: many specializations can increase the binary size.

Type introspection with @typeInfo

Since we have the type T available during compilation, we can inspect it.

Zig gives us the @typeInfo(T) function, which returns a tagged union describing the type (is it an integer? Is it a struct? What fields does it have?).

This allows us to write generic functions that adapt based on what they receive. For example, a function that only accepts integers:

fn add(comptime T: type, a: T, b: T) T {
    // Validate at compile time
    const info = @typeInfo(T);
    
    if (info != .int) {
        @compileError("This function only accepts integers!");
    }
    
    return a + b;
}
Copied!

If you try to call add(f32, 1.5, 2.5), the compiler will throw your own custom error and stop compilation.

Example: a linked list

Let’s put everything together to create a basic LinkedList.

const std = @import("std");

// The generic function
fn LinkedList(comptime T: type) type {
    return struct {
        // Define the Node within the type's scope
        const Node = struct {
            data: T,
            next: ?*Node,
        };

        head: ?*Node = null,
        allocator: std.mem.Allocator,

        // 'Self' refers to the type we are returning
        const Self = @This();

        pub fn init(allocator: std.mem.Allocator) Self {
            return Self{ .allocator = allocator };
        }

        pub fn add(self: *Self, value: T) !void {
            const new_node = try self.allocator.create(Node);
            new_node.* = Node{ .data = value, .next = self.head };
            self.head = new_node;
        }

        pub fn deinit(self: *Self) void {
            var current = self.head;
            while (current) |node| {
                const next = node.next;
                self.allocator.destroy(node);
                current = next;
            }
            self.* = undefined;
        }
    };
}

pub fn main() !void {
    var debug_allocator = std.heap.DebugAllocator(.{}){};
    defer _ = debug_allocator.deinit();

    // Create the concrete type
    const IntList = LinkedList(i32);
    
    var list = IntList.init(debug_allocator.allocator());
    defer list.deinit();

    try list.add(10);
    try list.add(20);
}
Copied!