Choosing Between Error Unions, Optionals, and Custom Result Types in Zig
A decision guide for Zig error handling: compare error unions, optionals, and custom result types, then see a concrete parsing example.
11 Oct 2025, 17:56 UTC

Decision and constraints
When designing a Zig API you must decide how to signal failure. The language provides three idiomatic ways: error unions (T!E), optionals (?T), and custom result structs. The choice depends on whether you need to preserve error details, want a lightweight success‑or‑absence signal, or need to return both a value and error information simultaneously.
Comparison of supported options
| Option | Pros | Cons | Typical use |
|---|---|---|---|
Error union (T!E) | Preserves exact error value, enables try/catch propagation, zero‑runtime overhead beyond the error set enum. | Caller must handle or explicitly discard with @ignoreErr; unhandled errors are a compile‑time error. | Functions where the reason for failure matters (parsing, I/O, resource allocation). |
Optional (?T) | Simple success/absence check, ergonomic with if (val) |x| or orelse, useful when failure reason is irrelevant. | Loses diagnostic information; forces caller to inspect the value only. | Lookup functions, factory methods that may return nil, or APIs wrapping C functions that use sentinel values. |
Custom result struct (struct { err: ErrorSet, value: T }) | Can return both a value and an error, enabling partial‑success patterns; explicit fields make intent clear. | More verbose, potential runtime cost if not optimized, requires manual unpacking. | Streaming parsers, batch operations where some items succeed and others fail, or when you want to accumulate errors. |
Trade‑off explanation
Error unions give the richest failure information with no extra cost, but they propagate the handling burden upward. Optionals erase the failure reason, simplifying the caller at the price of losing diagnostics. Custom results sit in the middle: they keep both pieces of data but introduce boilerplate and may inhibit certain optimizations if the struct is not trivial. The decision should be guided by the API’s contract: if the caller needs to react differently based on why something failed, prefer an error union; if the caller only cares about success or failure, an optional is sufficient; if you need to convey a value even when an error occurred (e.g., a partially parsed buffer), a custom result is appropriate.
Concrete implementation and validation
The following example shows three functions that parse a string into an integer, each using a different error‑handling style. The main function demonstrates how a caller would interact with each variant.
const std = @import("std");
const stdout = std.io.getStdOut().writer();
// Error union variant
pub fn parseIntUnion(s: []const u8) !i32 {
return std.fmt.parseInt(i32, s, 10) catch return error.ParseFailed;
}
// Optional variant
pub fn parseIntOptional(s: []const u8) ?i32 {
return std.fmt.parseInt(i32, s, 10) catch null;
}
// Custom result variant
const Result = struct {
err: error{ParseFailed},
value: i32,
};
pub fn parseIntResult(s: []const u8) Result {
return std.fmt.parseInt(i32, s, 10) catch |e| return .{ .err = e, .value = 0 };
}
pub fn main() void {
const inputs = [_][]const u8{ "42", "", "99" };
for (inputs) |inp| {
// Error union handling
if (parseIntUnion(inp)) |val| {
stdout.print("Union: {d}\n", .{val}) catch {};
} else |err| {
stdout.print("Union error: {}\n", .{err}) catch {};
}
// Optional handling
if (parseIntOptional(inp)) |val| {
stdout.print("Optional: {d}\n", .{val}) catch {};
} else {
stdout.print("Optional: none\n") catch {};
}
// Custom result handling
const res = parseIntResult(inp);
if (res.err) |e| {
stdout.print("Result error: {}\n", .{e}) catch {};
} else {
stdout.print("Result: {d}\n", .{res.value}) catch {};
}
stdout.print("---\n") catch {};
}
}
To verify the example:
- Save the code to a file named
error_demo.zig. - Run
zig build-exe error_demo.zig. The compiler will reject the file if any error union is left unhandled. - Execute the resulting binary with no arguments; it will process the hard‑coded test inputs and print success or error messages for each variant.
This exercise shows how each strategy appears in real code and lets you observe the handling differences without needing external dependencies.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.