home

Creating enums at comptime

Sep 19, 2024

In Basic MetaProgramming in Zig we saw how std.meta.hasFn uses @typeInfo to determine if the type is a struct, union or enum. In this post, we'll take expand that introduction and use std.builtin.Type to create our own type.

Pretend you're building a validation library which has a number of validation errors. You decide to define all of these in an enum:

// We use explicit ordinal values because the error code that we
// return will be based on this, and we want it to be stable.
pub const ValidationError = enum(i16) {
    required = 1,
    int_range = 2,
    string_length = 3,
};

You'd like users of your library to be able to define their own validation behavior including their own enum values. Ideally, you'd like to present your library enum and the app-specific enum as one cohesive type. To achieve this, we'll begin with a skeleton of our library's Validator:

// our library Validator
pub fn Validator(comptime V: type) type {
    return struct {
        const Self = @This();
        pub fn init() Self {
            return .{};
        }
    };
}

The reason this is a generic is to support app-specific validators. The user's app will define their own Validator where they can add custom behavior:

const ValidatorExtension = struct {
   pub const ValidationError = enum(i16) {
        name_reserved = 1000,
        category_limit = 1001,
   };
};

To create a validator, the user would do:

var validator = validation.Validator(ValidatorExtension);

The goal is to create a new type which merges the library's validation.Validator with the application's ValidatorExtension.

@Type

To create a type at comptime the @Type builtin is used. It takes a std.builtin.Type and creates a type (this process is called reify). It's the opposite of @typeInfo. We already know that std.builtin.Type is a union and, in this case, we're interested in the enum tag. That's a std.builtin.Type.Enum, which looks like:

pub const EnumField = struct {
    name: [:0]const u8,
    value: comptime_int,
};

pub const Enum = struct {
    tag_type: type,
    fields: []const EnumField,
    decls: []const Declaration,
    is_exhaustive: bool,
};

Creating a new enum requires that we create a new std.builtin.Type.Enum with the desired fields - in our case taking the fields from both types and merging them. We'll do this in two steps. First, we'll create an enum based solely on our library's own ValidationError. This code isn't actually useful, since it just creates a copy of ValidationError, but it keeps things simple:

fn BuildValidationError() type {
    // "enum" is a reserved keyword, so we need to escape it
    // with @"enum". Yes, it's annoying.

    // Get the fields of our ValidationError
    const lib_fields = @typeInfo(ValidationError).@"enum".fields;

    // Create a new array to hold the fields for the enum that we'll create
    var fields: [lib_fields.len]std.builtin.Type.EnumField = undefined;

    // Copy the fields over
    for (lib_fields, 0..) |f, i| {
        fields[i] = f;
    }

    // the type of the @"enum" tag is std.builtin.Type.Enum
    // we use the type inference syntax, i.e. .{...}
    return @Type(.{.@"enum" = .{
        .decls = &.{},
        .tag_type = i16,
        .fields = &fields,
        .is_exhaustive = true,
    }});
}

When creating a type like this, we can't define declarations (like methods), so we set decls to an empty slice. We'll come back to the tag_type in a bit. We set fields to the fields we just created (copied) from the original enum. Finally, Zig has non-exhaustive enums; they aren't germane to this topic, so we just set it to true since normal enums are exhaustive.

Instead of duplicating an enum, we actually want to merge two enums. But, with the above in place, this just comes down to merging two arrays:

fn BuildValidationError(comptime App: type) type {
    // Get the fields of our ValidationError
    const lib_fields = @typeInfo(ValidationError).@"enum".fields;

    // Get the fields of the App's ValidationError, if there is one
    const app_fields = blk: {
        if (@hasDecl(App, "ValidationError") == false) {
            break :blk &.{};
        }
        switch (@typeInfo(App.ValidationError)) {
            .@"enum" => |e| break :blk e.fields,
            else => @compileError(@typeName(App.ValidationError) ++ " must be an enum"),
        }
    };

    // Create an array that is big enough for all fields
    var fields: [lib_fields.len + app_fields.len]std.builtin.Type.EnumField = undefined;

    // Copy the library fields
    for (lib_fields, 0..) |f, i| {
        fields[i] = f;
    }

    // Copy the app fields
    // (we start our counter iterator, i, at lib_fields.len)
    for (app_fields, lib_fields.len..) |f, i| {
        fields[i] = f;
    }

    // Same as before
    return @Type(.{.@"enum" = .{
        .decls = &.{},
        .tag_type = i16,
        .fields = &fields,
        .is_exhaustive = true,
    }});
}

You'll notice that we're being defensive. Having an application-specific ValidationError is optional, so we can't assume App.ValidationError exists. Also, while we could leave out the enum type check, having an explicit check with our own error message results in a more user-friendly error should App.ValidationError be a different type.

Finally, we go back to our Validator skeleton and use the above function:

pub fn Validator(comptime V: type) type {
    return struct {
        pub const ValidationError = BuildValidationError(V);

        const Self = @This();
        pub fn init() Self {
            return .{};
        }
    };
}

Which means that: validation.Validator(ValidationExt).ValidationError is our merged enum.

In this example we gave our enums an explicit tag type of i16. We also gave every value an explicit value. Normally, you let Zig infer these. This made BuildValidationError a little easier to implement. If we hadn't given each tag an explicit value, the code would have failed to compile: error: enum tag value 0 already taken. That's because we would have tried to add 2 fields with the same value (both ValidationError.required and ValidatorExtension.ValidationError.name_reserved would have a value of 0.

For our use case, this is actually an advantage: if the application accidentally uses the same enum value, we get a compiler error. But, if we didn't have / want explicit values, we'd have to change our two for loop:

for (lib_fields, 0..) |f, i| {
    fields[i] = .{.name = f.name, .value = i};
}

for (app_fields, lib_fields.len..) |f, i| {
    // remember, i starts off at lib_fields.len
    fields[i] = .{.name = f.name, .value = i};
}

As for the tag_type, we could do exactly what Zig does and assign it to: std.math.IntFittingRange(0, fields.len - 1).

Conclusion

You might never need to create a type like this. But chances are that, on occasion, you will use @typeInfo along with std.builtin.Type and its child types. And, as you gain familiarity with these, it's useful to remember that the @Type builtin can be used to turn those into a concrete type.

As an aside, the best way to get comfortable with std.builtin.Type is to look at the output of @typeInfo:

pub fn main() !void {
    const User = struct {
        id: i32,
        name: []const u8,
    };
    std.debug.print("{any}\n", .{@typeInfo(User)});
}

Unfortunately, this doesn't work; you'll get an error complaining about some values being comptime-known while others are runtime-known. If you dig into the type and use an inline for to unwrap the for loop std.debug.print was trying to do (and failed to do) over the fields array, you can get meaningful output:

pub fn main() !void {
    const User = struct {
        id: i32,
        name: []const u8,
    };
    const info = @typeInfo(User).@"struct";
    inline for (info.fields) |f| {
        std.debug.print("{any}\n", .{f});
    }
}

Which outputs:

builtin.Type.StructField{
    .name = { 105, 100 },
    .type = i32,
    .default_value = null,
    .is_comptime = false,
    .alignment = 4
}

builtin.Type.StructField{
    .name = { 110, 97, 109, 101 },
    .type = []const u8,
    .default_value = null,
    .is_comptime = false, .alignment = 8
}

This helps give us an idea of how we could use @Type to create a struct.