Error payloads in Zig
Update (Feb 18, 2026): Thanks to everybody who gave feedback on the HN and Lobste.rs threads, especially to Andy and matklad who posted on his blog.
This pattern is only idiomatic in certain situations; see the “Isn’t this unidiomatic?” section below for more info.
I do error payloads in Zig by making a union(enum)-based
Diagnostics type for each function. These types have special methods which
remove code bloat at call sites. A Diagnostics
type can be defined inline, and the errorset can be generated inline from the
Diagnostic’s enum tag.
pub fn scan(
db: *c.sqlite,
diag: *diagnostics.FromUnion(union(enum) {
SqliteError: sqlite.ErrorPayload,
OutOfMemory: void,
LoadPluginsError: diagnostics.OfFunction(transforms.loadPlugins).ErrorPayload(error.LoadPluginsError),
}),
): diagnostics.Error(@TypeOf(diag))!void {
// ...
}
My diagnostics module as a gist
The generated type is a wrapper around an optional payload. It generates
an error set type from the union(enum) fields.
// diagnostics.zig
pub fn FromUnion(comptime _Payload: type) type {
return struct {
pub const Payload = _Payload;
pub const Error = ErrorSetFromEnum(std.meta.FieldEnum(Payload));
payload: ?Payload = null,
// ... methods ...
};
}
Update (Feb 18, 2026): Isn’t this unidiomatic?
According to the “error codes are for control flow” principle, this pattern is only idiomatic if the error codes and diagnostic payloads are used to give calling code the ability to actually handle the error.
In particular, if your code is an app which does not expose its internals as a library, internal errors which are not handled anywhere should probably just get logged at the source. Matklad’s post Diagnostic Factory has more information about this.
Zig’s
logFncan be customized in many creative ways if your goal is just to asynchronously show humans what went wrong. You could mark a subset of logs to be included in an HTTP response or displayed in a GUI. You could mark some logs to show up as their own alert dialog. You could attach an ID to each log call to let users whitelist or blacklist specific messages without needing to parse your logs. A library’s logFn could be configured at runtime by its users. Etc etc.
The first thing you will want to do is set a payload while you return
an error. For this, there is the withContext method.
pub fn countRows(
alloc: std.mem.Allocator,
db: *c.sqlite,
opts: Options,
diag: *diagnostics.FromUnion(union(enum) {
SqliteError: sqlite.ErrorPayload,
OutOfMemory: void,
}),
) !usize {
const st = sqlite.prepareStmt(
alloc,
db,
"SELECT COUNT(*) FROM {0s} WHERE ({1s})",
.{ opts.table_name, opts.where_expr },
) catch |err| return switch (err) {
error.SqliteError => diag.withContext(error.SqliteError, .init(db)),
error.OutOfMemory => error.OutOfMemory,
};
// ...
}
Here, sqlite.ErrorPayload.init saves 500 bytes of error message from
sqlite. That payload gets saved to diag and the error is returned.
You would expect callsites to need tons of boilerplate, but it’s actually very common to just need to value copy a payload from one diag to another, and this can be done in a single line of code.
pub const BuildDiagnostics = diagnostics.FromUnion(union(enum) {
SqliteError: sqlite.ErrorPayload,
OutOfMemory: void,
// ... 15 more ...
});
pub fn build(..., diag: *BuildDiagnostics) !void {
// Choose N chunks
const n_rows = try diag.call(countRows, .{ alloc, db, opts });
const n_chunks = @max(1, n_rows / opts.chunk_size);
}
The countRows func needs 4 arguments, but the tuple only has 3.
The call method inspects
the type of countRows to determine the type of its diag arg,
instantiates the diag, calls countRows, and if there is an error, copies the
error payload to the *BuildDiagnostics.
Written explicitly, this call would be around 5 lines of code.
pub fn build(..., diag: *BuildDiagnostics) !void {
// Choose N chunks
var count_rows_diag: diagnostics.OfFunction(countRows) = .{};
const n_rows = countRows(alloc, db, opts, &count_rows_diag) catch |err| return switch (err) {
error.SqliteError => diag.withContext(error.SqliteError, count_rows_diag.get(error.SqliteError)),
error.OutOfMemory => error.OutOfMemory,
}
const n_chunks = @max(1, n_rows / opts.chunk_size);
}
At the edges, the error payload is accessible for logging or other purposes.
fn logBuildError(diag: build.BuildDiagnostics, err: build.BuildDiagnostics.Error) void {
switch (err) {
error.LoadPluginError => if (diag.get(error.LoadPluginError)) |info| {
std.log.err("failed to load plugin '{s}': {s}", .{ info.name, @errorName(info.err) });
} else {
std.log.err("failed to load plugin: unknown error", .{});
},
// ... (handle many other errors) ...
}
}
ZLS can’t infer the result of the diag.call invocations, so it can be useful to put
in an explicit type annotations.