Creating enums at comptime
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.