Zig NEWS

Cover image for Sneaky Error Payloads
Isaac Yonemoto
Isaac Yonemoto

Posted on

Sneaky Error Payloads

PC: maritime new zealand

A common gripe by newcomers to Zig is that error returns can't contain payloads.

Zig takes from the simplicity of C, where usually errors are returned. However, C often runs into tricky situations where errors are special-cased values in the full range of the raw type (e.g. negative numbers, or 0), and obtaining further error information might require checking a side-channel.

Other modern languages, such as C++, Java, and Rust, allow you to error return with a structured datatype (a process usually called "raising") that has to be "caught" via specialized control flow.

A common use case where you would want a raising operation is to early-quit a text tokenization or parsing operation, where your early-quit contains line/column and content information.

In this article I can show you how you can sneak an error payload into your errorable zig function, without language-level support for such a thing.

First, the code:

const std = @import("std");

const CustomError = error{NumberError};

pub fn raise(message: []const u8, value: u64, opts: anytype) CustomError {
    comptime if (has_error(@TypeOf(opts))) {
        opts.error_payload.message = message;
        opts.error_payload.value = value;
    };
    return CustomError.NumberError;
}

fn has_error(comptime T: type) bool {
    return @hasField(T, "error_payload");
    // consider checking for error allocator, too, if necessary.
}

fn add_one(number: u64, opts: anytype) CustomError!u64 {
    if (number == 42) {
        return raise("bad number encountered", number, 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
    const Payload = struct {
        message: []const u8 = undefined,
        value: u64 = undefined,
    };

    var payload = Payload{};
    var opts = .{ .error_payload = &payload };

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

    _ = add_one(42, opts) catch trap2: {
        std.debug.print("errored with payload: {s}, value {}! \n", .{ payload.message, payload.value });
        break :trap2 0;
    };
}
Enter fullscreen mode Exit fullscreen mode

Let's take a look at the core function first:

fn add_one(number: u64, opts: anytype) CustomError!u64 {
    if (number == 42) {
        return raise("bad number encountered", number, opts);
    } else {
        return number + 1;
    }
}
Enter fullscreen mode Exit fullscreen mode

Here, we're going to check on some condition (in this case, a silly number is sent to the function) and if the failure condition is identified, we are going to return a payload.

But where is the payload? It's going to be in the opts parameter. The cool thing is that in a lot of functions that you would want to return a payload, you're likely to have some level of customization.

The key strategy for payload management is in the cheekily named raise function:

pub fn raise(message: []const u8, value: u64, opts: anytype) CustomError {
    comptime if (has_error(@TypeOf(opts))) {
        opts.error_payload.message = message;
        opts.error_payload.value = value;
    };
    return CustomError.NumberError;
}
Enter fullscreen mode Exit fullscreen mode

Here we perform a comptime check to see if we have an error_payload field in our opts. Note that if do include error_payload but it's missing message or value (or they have the wrong type), calling add_one will yield a compiler error.

In order to trigger error payloads, we have to load up our opts with the error payload, which happens here:

    var opts = .{ .error_payload = &payload };
Enter fullscreen mode Exit fullscreen mode

Don't forget to load up a pointer into error_payload field! The compiler treats anonymous struct parameters as const, and will stop you from calling the function.

While there is considerably more boilerplate to achieve a error payload, the biggest advantage to doing things this way is that if a caller doesn't need the payload content, the work that the payload would have to do gets completely elided away and never compiled, but the erroring process still works as expected.

What if I need an allocator?

If your payload needs an allocator to construct its final form, you could pass it into the opts as a required error_allocator. This could also be checked at compile time. And what should happen if the error allocator fails? That's up to you!

What changes might make this easier?

One thing that Zig could do to make this pattern more accessible is to allow error namespaces to have function declarations, like so:

const CustomError = error{
    NumberError,
    pub fn raise(...) @This() {...}
};
Enter fullscreen mode Exit fullscreen mode

this would highly increase the legibility by immediately associating the process of "raising" with the associated error type.

Conclusion

Hopefully this article gives you a suggestion of how to handle error payloads in zig. It's quite possible to do, without further language support. The pattern that I show in this article is flexible, and uses the compile-time duck-typing properties of zig. The compiler is very helpful in checking that all the things you need in the error payload struct are there and ready to be set. Happy Zigging!

Final Notes

  • Note that this is not necessarily the best way of achieving this result, there may be ways of being more typesafe with payloads across multiple error types.
  • For the use case that I suggested earlier in the article, tokenization and parsing, you probably don't want to implement early returns in this fashion. The Zig compiler tokenization and parsing instead operates (roughly) by returning an explicit tagged union of a failure struct and result struct, because of three reasons:
    1. failures are highly expected
    2. there are no cases where you want to optimize out the payload
    3. You don't really need the cost of stacktrace management for these failure modes.
  • You can share your payload struct between multiple different error types/raise functions as long as they have intercompatible payload types.
  • For an extra level of safety, you may want to consider making some of your payload fields optional, setting the initial value to null, instead of undefined. For most cases, so long as you handle your payload only in the catch block, they should be set.

Top comments (5)

Collapse
 
batiati profile image
Rafael Batiati

I wish we had union(error) as a first class citizen in Zig.

Yes, I know, each payload has its requirements for init/deinit and Zig can't magically handle that, I know.

But, it can be solved if the caller is forced to handle a function that returns a union(error) or popup exactly the same type. No inferred errors should be allowed for this use case. This way, the caller is always responsible to clean up any resources a payload uses (e.g call payload.deinit();)

Example:

pub const CustomError = union(error) {
    SimpleError: void,
    ErrorWithPayload: []const u8,
};

pub fn add_one(number: u64) CustomError!u64 {
    if (number == 42) {
        return CustomError { .ErrorWithPayload = "bad number encountered" }; 
    } else {
        return number + 1;
    }
};

//Correct usage
pub fn use_it_handling_the_error() void {
    _ = add_one(42) catch |err| switch(err) {
        .ErrorWithPayload => |payload| std.debug.print("errored with payload: {s}! \n", .{ payload }),
        _ => std.debug.print("errored a simple error!\n", .{ }),
   };
}

//Another correct usage
pub fn use_it_returning_the_same_error() CustomError!void {
    _ = try add_one(42);
}

//Incorrect usage (compiler error)
pub fn use_it_inferring_the_error() !void {
    _ = try add_one(42);
}

Enter fullscreen mode Exit fullscreen mode

PS: Yes, we can do that in user land already, but without being able to use try/catch.

pub const CustomResult = union(enum) {
    Success: u64,
    SimpleError: void,
    ErrorWithPayload: []const u8,
};

pub fn add_one(number: u64) CustomResult {
    if (number == 42) {
        return CustomResult { .ErrorWithPayload = "bad number encountered" }; 
    } else {
        return CustomResult { .Success = number + 1 };
    }
};
Enter fullscreen mode Exit fullscreen mode
Collapse
 
minosian profile image
Ara

Please change " comptime if (has_error(@TypeOf(opts))) {" to "
if (@hasField(@TypeOf(opts), "error_payload")) {" This eliminates the error and makes code more readable. @hasField is known in compile time, therefore branch can be removed by compiler.

Collapse
 
danielchasehooper profile image
Daniel Hooper

I don't see what the compile time reflection gets you here. The same thing can be accomplished more simply with an optional pointer.

const std = @import("std");

const CustomError = error{NumberError};

const NumberErrorPayload = struct {
message: []const u8 = undefined,
value: u64 = undefined,
};

pub fn raise(message: []const u8, value: u64, out_error: ?*NumberErrorPayload) CustomError {
if (out_error) |opts|{
opts.message = message;
opts.value = value;
}
return CustomError.NumberError;
}

fn add_one(number: u64, out_error: ?*NumberErrorPayload) CustomError!u64 {
if (number == 42) {
return raise("bad number encountered", number, out_error);
} else {
return number + 1;
}
}

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

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

var payload = NumberErrorPayload{};

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

_ = add_one(42, &payload) catch trap2: {
    std.debug.print("errored with payload: {s}, value {}! \n", .{ payload.message, payload.value });
    break :trap2 0;
};
Enter fullscreen mode Exit fullscreen mode

}

Collapse
 
ityonemo profile image
Isaac Yonemoto • Edited

You're checking the out_error for nullity every time you call the function.

Having said that, the optional parameter works nicely too, and (this is a matter of taste, I suppose) easier to read in the case you don't have other parameter options.

Collapse
 
minosian profile image
Ara

orig_main.zig:7:9: error: unable to evaluate comptime expression
opts.error_payload.message = message;
^~~~