Somewhere I read stuff about users wanting errors with payloads, then I read stuff about zigs destructuring syntax for tuples and I began wondering whether using destructuring syntax could help in creating custom errors with payloads. I nerd-sniped myself
So I began an exploration (procrastination) journeyā¦
Here is what I came up with, custom errors are structs like this:
const UnexpectedToken = struct {
expected: Token,
got: Token,
location: Location,
pub fn init(expected: Token, got: Token, location: Location) @This() {
return .{ .expected = expected, .got = got, .location = location };
}
pub inline fn err(_: @This()) !void {
return error.UnexpectedToken;
}
pub fn format(
self: UnexpectedToken,
comptime fmt: []const u8,
options: std.fmt.FormatOptions,
writer: anytype,
) !void {
_ = fmt;
_ = options;
try writer.writeAll("UnexpectedToken\n");
try writer.print(" expected: {}\n", .{self.expected});
try writer.print(" got: {}\n", .{self.got});
try writer.print(" location: {}\n", .{self.location});
}
};
The only really essential method is err
the rest is just fluff to make initialization/formatting nicer.
How do you write a function returning custom errors?
const payload = customerrors.Payload(.{});
const TokenOrFile = customerrors.Union(.{ UnexpectedToken, FileFetchError });
const NumbersErrors = customerrors.Union(.{ customerrors.AllocationError, TokenOrFile });
const Numbers = payload.Error(*Node, NumbersErrors);
fn parseNumbers(self: *Parser) Numbers.res {
const choice = self.rng.random().uintLessThan(u16, 10);
if (choice == 0) {
return Numbers.fail(UnexpectedToken.init(.NUMBER, .SOMETHING_NOT_ALLOWED_IN_PARENS, .{
.file = fakefile,
.line = 10,
.column = 33,
}));
} else if (choice == 1) {
return Numbers.fail(FileFetchError{ .file = fakefile, .pos = 42 });
} else {
var node = self.randomNodeAllocFail() catch {
return Numbers.fail(customerrors.AllocationError{ .src = @src() });
};
node.data = self.rng.random().uintLessThan(u16, 1000);
return Numbers.success(node);
}
}
Here customerrors.Union
combines N different errors into a union so we can return any of them, we have to do this explicitly because we donāt have a way to infer it, but we can combine unions into bigger unions (they get flattened).
payload.Error(*Node, NumbersErrors)
defines that we have a payload *Node
and a custom error (union) NumbersErrors
. Numbers
is a struct with functions that are the interface used for writing functions with custom errors, res
is the result type which is always a 2 element tuple (kind of like in go, but try makes it nicer?) where the first is the result of the function (only defined if successful) and the second is an optional of the custom-error type, simplified you can say that success
returns .{value, null}
and fail
returns .{undefined, custom-error}
however fail constructs the union instance based on the given type automatically and can ādisableā custom errors (e.g. based on build flags) always returning OpaqueError
which doesnāt have more information than zig error codes.
How do you use a function?
fn parse(self: *Parser) !void {
const node1 = try payload.unwrap(self.parseNumbers());
const node2, const err = self.parseNumbers();
try payload.check(err);
const node3, const err2 = self.parseNumbers();
payload.custom(err2) catch |e| {
std.debug.print("the custom error was:\n{}\n", .{err2.?});
std.debug.print("the error code is: {}\n", .{e});
std.debug.dumpCurrentStackTrace(null); // TODO better stack trace
return e;
};
const node4, const err3 = self.parseNumbers();
if (err3) |custom_error| {
std.debug.print("using if on the optional:\n{}\n", .{custom_error});
std.debug.dumpCurrentStackTrace(null); // TODO better stack trace
return custom_error.err();
}
// use nodes
std.debug.print("{} {} {} {}\n", .{ node1, node2, node3, node4 });
}
Here payload.unwrap
gets the 2-tuple returned from parseNumbers
and returns its success value if its successful and on fail it prints the custom error and fails with the zig error code, so unwrap uses the custom error information in a predefined way (it might make sense to have a customization function for this) and converts it to a zig error.
payload.check(err)
here err is the custom error and the function prints the custom error and fails with the zig error on failure, otherwise it does nothing.
payload.custom(err2)
fails with the zig error without printing anything on failure, otherwise does nothing.
And you also can use if on the optional custom error value.
There are a bunch of things that could be explored further here:
- when custom errors are ādisabledā how much of the code really disappears from what gets compiled into the program? I havenāt looked into thisā¦
- in functions that use custom errors can we capture stack trace information properly and print one unified stack trace that looks similar to a zig error just with more information?
- we are converting custom errors to zig errors, maybe the other way around also makes sense sometimes?
- what about resources being associated with errors?
- should the generated union fields have better names?
- switching on the custom error union would be better with predictable field names!?!
Here is my sketch of the idea:
This topic is about what can be done with current zig, however here is a link to an issue about adding a feature to the language: Allow returning a value with an error Ā· Issue #2647 Ā· ziglang/zig Ā· GitHub
I currently donāt have a usecase for errors with payloads (maybe in the future when I revisit my interpreter). Do you use custom errors of some kind? What are your use cases? What features do you miss? Is this useful?
If this idea is useful, maybe we can work together on creating a polished version of this, that can be used as a library.