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;
};
}
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;
}
}
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;
}
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 };
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() {...}
};
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:
- failures are highly expected
- there are no cases where you want to optimize out the payload
- 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.
Latest comments (5)
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.
orig_main.zig:7:9: error: unable to evaluate comptime expression
opts.error_payload.message = message;
^~~~
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 callpayload.deinit();
)Example:
PS: Yes, we can do that in user land already, but without being able to use try/catch.
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)});
}
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.