Overview
Chapter 4 introduced the mechanics of Zig’s error unions, try, and errdefer; this appendix turns those ideas into a quick-reference cookbook you can consult while sketching new APIs or refactoring existing ones. Each recipe tightens the link between domain-specific error vocabularies and the diagnostic messages your users ultimately see.
Zig 0.15.2 refined diagnostics around integer casts and allocator failures, making it easier to rely on precise error propagation in both debug and release-safe builds.v0.15.2
Learning Goals
- Layer domain-specific error sets on top of standard Zig I/O failures without losing precision.
- Guard heap-backed transformations with
errdeferso that every exit path pairs allocations and deallocations. - Translate internal error unions into clear, actionable messages for logs and user interfaces.
_Refs: _
Layered Error Vocabularies
When a subsystem introduces its own error conditions, refine the vocabulary instead of throwing everything into anyerror. The pattern below composes a configuration-specific union from parsing failures and simulated I/O errors so the caller never loses track of NotFound versus InvalidPort.4 The catch |err| switch idiom keeps the mapping honest and mirrors how std.fmt.parseInt surfaces parsing issues.fmt.zig
//! Demonstrates layering domain-specific error sets when loading configuration.
const std = @import("std");
pub const ParseError = error{
MissingField,
InvalidPort,
};
pub const SourceError = error{
NotFound,
PermissionDenied,
};
pub const LoadError = SourceError || ParseError;
const SimulatedSource = struct {
payload: ?[]const u8 = null,
failure: ?SourceError = null,
fn fetch(self: SimulatedSource) SourceError![]const u8 {
if (self.failure) |err| return err;
return self.payload orelse SourceError.NotFound;
}
};
fn parsePort(text: []const u8) ParseError!u16 {
var iter = std.mem.splitScalar(u8, text, '=');
const key = iter.next() orelse return ParseError.MissingField;
const value = iter.next() orelse return ParseError.MissingField;
if (!std.mem.eql(u8, key, "PORT")) return ParseError.MissingField;
return std.fmt.parseInt(u16, value, 10) catch ParseError.InvalidPort;
}
pub fn loadPort(source: SimulatedSource) LoadError!u16 {
const line = source.fetch() catch |err| switch (err) {
SourceError.NotFound => return LoadError.NotFound,
SourceError.PermissionDenied => return LoadError.PermissionDenied,
};
return parsePort(line) catch |err| switch (err) {
ParseError.MissingField => return LoadError.MissingField,
ParseError.InvalidPort => return LoadError.InvalidPort,
};
}
test "successful load yields parsed port" {
const source = SimulatedSource{ .payload = "PORT=8080" };
try std.testing.expectEqual(@as(u16, 8080), try loadPort(source));
}
test "parse errors bubble through composed union" {
const source = SimulatedSource{ .payload = "HOST=example" };
try std.testing.expectError(LoadError.MissingField, loadPort(source));
}
test "source failures remain precise" {
const source = SimulatedSource{ .failure = SourceError.PermissionDenied };
try std.testing.expectError(LoadError.PermissionDenied, loadPort(source));
}
$ zig test 01_layered_error_sets.zigAll 3 tests passed.Preserve the original error names all the way to your API boundary—callers can branch on LoadError.PermissionDenied explicitly, which is more robust than string matching or sentinel values.36
errdefer for Balanced Cleanup
String assembly and JSON shaping often allocate temporary buffers; forgetting to free them when a validation step fails leads straight to leaks. By pairing std.ArrayListUnmanaged with errdefer, the next recipe guarantees both success and failure paths clean up correctly while still returning a convenient owned slice.13 Every allocation helper used here ships in the standard library, so the same structure scales to more complex builders.array_list.zig
//! Shows how errdefer keeps allocations balanced when joining user snippets.
const std = @import("std");
pub const SnippetError = error{EmptyInput} || std.mem.Allocator.Error;
pub fn joinUpperSnippets(allocator: std.mem.Allocator, parts: []const []const u8) SnippetError![]u8 {
if (parts.len == 0) return SnippetError.EmptyInput;
var list = std.ArrayListUnmanaged(u8){};
errdefer list.deinit(allocator);
for (parts, 0..) |part, index| {
if (index != 0) try list.append(allocator, ' ');
for (part) |ch| try list.append(allocator, std.ascii.toUpper(ch));
}
return list.toOwnedSlice(allocator);
}
test "joinUpperSnippets capitalizes and joins input" {
const allocator = std.testing.allocator;
const result = try joinUpperSnippets(allocator, &[_][]const u8{ "zig", "cookbook" });
defer allocator.free(result);
try std.testing.expectEqualStrings("ZIG COOKBOOK", result);
}
test "joinUpperSnippets surfaces empty-input error" {
const allocator = std.testing.allocator;
try std.testing.expectError(SnippetError.EmptyInput, joinUpperSnippets(allocator, &[_][]const u8{}));
}
$ zig test 02_errdefer_join_upper.zigAll 2 tests passed.Because the standard testing allocator trips on leaks automatically, exercising both the success and error branches doubles as a regression harness for future edits.13
Translating Errors for Humans
Even the best-crafted error sets need to land with empathetic language. The final pattern demonstrates how to keep the original ApiError for programmatic callers while producing human-readable prose for logs or UI copy.36std.io.fixedBufferStream makes the output deterministic for tests, and the dedicated formatter isolates messaging from control flow.log.zig
//! Bridges domain errors to user-facing log messages.
const std = @import("std");
pub const ApiError = error{
NotFound,
RateLimited,
Backend,
};
fn describeApiError(err: ApiError, writer: anytype) !void {
switch (err) {
ApiError.NotFound => try writer.writeAll("resource not found; check identifier"),
ApiError.RateLimited => try writer.writeAll("rate limit exceeded; retry later"),
ApiError.Backend => try writer.writeAll("upstream dependency failed; escalate"),
}
}
const Action = struct {
outcomes: []const ?ApiError,
index: usize = 0,
fn invoke(self: *Action) ApiError!void {
if (self.index >= self.outcomes.len) return;
const outcome = self.outcomes[self.index];
self.index += 1;
if (outcome) |err| {
return err;
}
}
};
pub fn runAndReport(action: *Action, writer: anytype) !void {
action.invoke() catch |err| {
try writer.writeAll("Request failed: ");
try describeApiError(err, writer);
return;
};
try writer.writeAll("Request succeeded");
}
test "runAndReport surfaces friendly error message" {
var action = Action{ .outcomes = &[_]?ApiError{ApiError.NotFound} };
var buffer: [128]u8 = undefined;
var stream = std.io.fixedBufferStream(&buffer);
try runAndReport(&action, stream.writer());
const message = stream.getWritten();
try std.testing.expectEqualStrings("Request failed: resource not found; check identifier", message);
}
test "runAndReport acknowledges success" {
var action = Action{ .outcomes = &[_]?ApiError{null} };
var buffer: [64]u8 = undefined;
var stream = std.io.fixedBufferStream(&buffer);
try runAndReport(&action, stream.writer());
const message = stream.getWritten();
try std.testing.expectEqualStrings("Request succeeded", message);
}
$ zig test 03_error_reporting_bridge.zigAll 2 tests passed.Keep the bridge function pure—it should only depend on the error payload and a writer—so consumers can swap logging backends or capture diagnostics in-memory during tests.36
Patterns to Keep on Hand
- Bubble lower-level errors verbatim until the last responsible boundary, then convert them in one place to keep invariants obvious.4
- Treat
errdeferas a handshake: every allocation or file open should have a matching cleanup within the same scope.fs.zig - Give each public error union a dedicated formatter so documentation and user messaging never drift apart.36
Notes & Caveats
- Merging error sets with
||preserves tags but not payload data; if you need structured payloads, reach for tagged unions instead. - Allocator-backed helpers should surface
std.mem.Allocator.Errordirectly—callers expect totryallocations just like standard library containers. - The recipes here assume debug or release-safe builds; in release-fast you may want additional logging for branches that would otherwise fire
unreachable.37
Exercises
- Extend
loadPortso it returns a structured configuration object with both host and port, then enumerate the resulting composite error set.4 - Add a streaming variant of
joinUpperSnippetsthat writes to a user-supplied writer instead of allocating, and compare its ergonomics.Io.zig - Teach
runAndReportto redact identifiers before logging by injecting a formatter callback—verify with unit tests that both success and failure paths respect the hook.36
Alternatives & Edge Cases
- For long-running services, consider wrapping retry loops with exponential backoff and jitter; Chapter 29 revisits the concurrency implications.29
- If your error bridge needs localization, store message IDs alongside the error tags and let higher layers format the final string.
- Embedded targets with tiny allocators may prefer stack-based buffers or fixed
std.BoundedArrayinstances over heap-backed arrays to avoidOutOfMemory.10