Zig NEWS

Cover image for Error Payloads (updated!)
Isaac Yonemoto
Isaac Yonemoto

Posted on

Error Payloads (updated!)

PC: maritime new zealand

When I wrote the previous post (https://zig.news/ityonemo/sneaky-error-payloads-1aka) it bothered me that the error payloads coming back didn't nicely associate the error enum with a payload type. I wondered, is it possible to rewrite it so that each error enum is associated with its own (possibly distinct) payload?

It is! However, it's somewhat annoying. Here is the code (note: this is in zig 0.10, so there is drift from the equivalent code in zig 0.11 / current master):

const std = @import("std");

fn ErrorPayloadFor(comptime ErrorSet: type, comptime types: anytype) type {
    const errors = switch(@typeInfo(ErrorSet)) {
        .ErrorSet => |e| e.?,
        else => {
            const msg = std.fmt.comptimePrint("ErrorSet parameter must be an error set, got .{}",  .{@TypeOf(ErrorSet)});
            @compileError(msg);
        }
    };

    const error_count = errors.len;

    comptime var union_fields: [error_count]std.builtin.Type.UnionField = undefined;
    comptime var enum_fields: [error_count]std.builtin.Type.EnumField = undefined;

    for (errors) |e, index| {
        const T = @field(types, e.name);

        union_fields[index] = .{
            .name = e.name,
            .field_type = T,  // name change after 0.10
            .alignment = @alignOf(T)
        };

        enum_fields[index] = .{
            .name = e.name,
            .value = index,
        };
    }

    const enum_info = std.builtin.Type.Enum {
        .layout = .Auto, //deprecated after 0.10
        .tag_type = u16, // errors must fit inside of u16
        .fields = &enum_fields,
        .decls = &[0]std.builtin.Type.Declaration{},
        .is_exhaustive = true,
    };

    const ErrorEnums = @Type(.{.Enum = enum_info});

    const union_info = std.builtin.Type.Union {
        .layout = .Auto,
        .tag_type = ErrorEnums,
        .fields = &union_fields,
        .decls = &[0]std.builtin.Type.Declaration{}
    };

    return @Type(.{.Union = union_info});
}

const CustomError = error{number_error, other_error};
const CustomErrorPayload = ErrorPayloadFor(CustomError, .{
    .number_error = struct {
        number: u64,
    },
    .other_error = struct {
        message: []const u8,
    },
});


fn error_print(ep: CustomErrorPayload) void {
    switch (ep) {
        .number_error => |payload| {
            std.debug.print("number error: {}\n", .{payload.number});
        },
        .other_error => |payload| {
            std.debug.print("other error: {s}\n", .{payload.message});
        }
    }
}

pub fn raise(comptime e: CustomError, payload: anytype, opts: anytype) CustomError {
    if (@hasField(@TypeOf(opts), "error_payload")) {
        switch (e) {
            error.number_error => opts.error_payload.* = .{.number_error = payload},
            error.other_error => opts.error_payload.* = .{.other_error = payload},
        }
    }
    return e;
}

fn add_one(number: u64, opts: anytype) CustomError!u64 {
    switch (number) {
        42 => return raise(error.number_error, .{.number = 42}, opts),
        13 => return raise(error.other_error, .{.message = "unlucky"}, opts),
        else => return number + 1,
    }
}

pub fn main() !void {
    std.debug.print("no error: {}\n", .{try add_one(1, .{})});

    _ = add_one(42, .{}) catch trap1: {
        std.debug.print("errored without payload! \n", .{});
        // we need this to have a matching return type
        break :trap1 0;
    };

    // here we define the payload we'd like to retrieve
    var payload: CustomErrorPayload = undefined;
    const opts = .{ .error_payload = &payload };

    std.debug.print("no error: {}\n", .{try add_one(1, opts)});

    _ = add_one(42, opts) catch {
        error_print(payload);
    };

    _ = add_one(13, opts) catch {
        error_print(payload);
    };
}
Enter fullscreen mode Exit fullscreen mode

The key is to define the ErrorPayloadFor function.

Let's take a look:

fn ErrorPayloadFor(comptime ErrorSet: type, comptime types: anytype) type 
Enter fullscreen mode Exit fullscreen mode

The header takes the errorset that you're associating with a payload, with an anonymous struct. This struct should have keys that match the error enums and values which are the associated payload type. We'll look at the invocation later.

    const errors = switch(@typeInfo(ErrorSet)) {
        .ErrorSet => |e| e.?,
        else => {
            const msg = std.fmt.comptimePrint("ErrorSet parameter must be an error set, got .{}",  .{@TypeOf(ErrorSet)});
            @compileError(msg);
        }
    };
Enter fullscreen mode Exit fullscreen mode

Above we assert that the errors parameter is indeed an error set, and retrieve the reflection data for the errors.

    const error_count = errors.len;

    comptime var union_fields: [error_count]std.builtin.Type.UnionField = undefined;
    comptime var enum_fields: [error_count]std.builtin.Type.EnumField = undefined;
Enter fullscreen mode Exit fullscreen mode

Above we set up some storage for the reflection data. We'll have to build up fields for two types: An enclosed Enum type (which will exactly match the names in the error enum), and the Union type which is the output. Note that the enclosed Enum is necessary because zig doesn't allow you to build a tagged union off of an error enum.

    for (errors) |e, index| {
        const T = @field(types, e.name);

        union_fields[index] = .{
            .name = e.name,
            .field_type = T,  // name change after 0.10
            .alignment = @alignOf(T)
        };

        enum_fields[index] = .{
            .name = e.name,
            .value = index,
        };
    }
Enter fullscreen mode Exit fullscreen mode

This for loop populates the union and enum fields with exactly what we expect.

    const enum_info = std.builtin.Type.Enum {
        .layout = .Auto, //deprecated after 0.10
        .tag_type = u16, // errors must fit inside of u16
        .fields = &enum_fields,
        .decls = &[0]std.builtin.Type.Declaration{},
        .is_exhaustive = true,
    };

    const ErrorEnums = @Type(.{.Enum = enum_info});
Enter fullscreen mode Exit fullscreen mode

This code reifies the EnumInfo metadata into a proper enum type.

    const union_info = std.builtin.Type.Union {
        .layout = .Auto,
        .tag_type = ErrorEnums,
        .fields = &union_fields,
        .decls = &[0]std.builtin.Type.Declaration{}
    };

    return @Type(.{.Union = union_info});
}
Enter fullscreen mode Exit fullscreen mode

once the enclosed Enum Type has been reified we can finally! build the union out of it.

Building the error payload type is simple:

const CustomErrorPayload = ErrorPayloadFor(CustomError, .{
    .number_error = struct {
        number: u64,
    },
    .other_error = struct {
        message: []const u8,
    },
});
Enter fullscreen mode Exit fullscreen mode

As you can see we can directly mainline the payload structs into the initialization tuple. Or you could define them previously (as consts) and supply those as values.

What could be better?

If zig supported creating unions out of error enum types, it would go a very long way to being better. The two major things it would improve would be:

  • not having to have the enclosed, discarded Enum in the target type. For debugger purposes, it could cause confusion because the integer representation for the error is very likely to be different from the integer representation of the tag.
  • the ability to bind "member functions" to the payload type: In the example, the print function has to be external to the payload because declarations are still code-privileged and we can't synthetically create declarations using reflection. Cosmetically, calling payload.print() is much more satisfying, and this is impossible without support for error enum unions.

Top comments (0)