Zig Error Unions: A Practical Guide to Safer File I/O
Zig’s error unions let you return a value or an error, forcing callers to handle every case. In file I/O this eliminates boilerplate and hidden failures. Read a practical example, trade‑offs, and a quick checklist to get started.
04 Sept 2025, 09:22 UTC

Why File I/O Is a Pain in the Neck
When you write a small utility that reads a configuration file, you quickly run into a tangle of failure paths: the file might not exist, the process may lack read permissions, the file could be truncated, or the JSON inside could be malformed. In C and many other languages you end up sprinkling if checks or propagating errno values across every layer. The result is verbose, easy to get wrong, and hard to read.
Thesis: Zig’s Error Unions Turn Boilerplate into Zero‑Cost Safety
Zig’s anyerror and user‑defined error sets let a function return a value *or* an error, but not both. The compiler forces the caller to handle every error case, so silent failures become impossible. This feature is a game‑changer for file I/O, where errors are plentiful and critical.
How It Works: A Quick Primer on Error Unions
In Zig a function can declare its return type as a union of a value and an error set:
pub fn openFile(path: []const u8) !File {
// implementation
}
The exclamation mark (!) indicates an error union. The compiler rewrites the function to return File on success or one of the errors in its set on failure. The caller must use try or catch to handle the result:
const file = try openFile("config.json");
If openFile fails, the runtime jumps to the nearest catch block, and the error is guaranteed to be one of the declared ones.
Concrete Example: Reading and Parsing a JSON Config
Below is a minimal, self‑contained example that demonstrates how to use error unions for robust file I/O. It compiles with Zig 0.13.x.
const std = @import('std');
const json = std.json;
const fs = std.fs;
// Custom error set for our read operation
const ReadError = error{ FileNotFound, PermissionDenied, ReadFailed, ParseFailed };
// Simple configuration structure
const Config = struct {
host: []const u8,
port: u16,
};
/// Reads a JSON file and returns a Config or a ReadError
pub fn readConfig(path: []const u8) !Config {
const file = try fs.cwd().openFile(path, .{ .mode = .read });
defer file.close();
const allocator = std.heap.page_allocator;
const content = try file.readAllAlloc(allocator, 1024 * 1024);
defer allocator.free(content);
var reader = json.Parser.init(allocator);
defer reader.deinit();
const parsed = try reader.parse(content);
// Expect the JSON to be an object with "host" and "port"
const obj = parsed.objectOrNull() orelse return ReadError.ParseFailed;
const host = obj.get("host") orelse return ReadError.ParseFailed;
const port = obj.get("port") orelse return ReadError.ParseFailed;
return Config{ .host = host, .port = @intCast(u16, port) };
}
pub fn main() !void {
const configPath = "config.json";
const config = readConfig(configPath) catch |err| {
switch (err) {
ReadError.FileNotFound => {
std.debug.print("{s}: file not found\n", .{configPath});
return err;
},
ReadError.PermissionDenied => {
std.debug.print("{s}: permission denied\n", .{configPath});
return err;
},
ReadError.ReadFailed => {
std.debug.print("{s}: read failed\n", .{configPath});
return err;
},
ReadError.ParseFailed => {
std.debug.print("{s}: JSON parse error\n", .{configPath});
return err;
},
else => {
std.debug.print("{s}: unknown error\n", .{configPath});
return err;
},
}
};
std.debug.print("Config: host={s}, port={d}\n", .{config.host, config.port});
}
Key points in the example:
- The
readConfigfunction declares!Configas its return type, meaning it can return aConfigor anyReadError. - Each system call that can fail returns an error union, and we propagate them using
try. If any step fails, control jumps to thecatchblock inmain. - The
catchblock pattern‑matches on the exact error type, allowing fine‑grained handling. - Because the compiler knows the set of possible errors, you cannot accidentally ignore an error; the code will not compile if a case is missing.
Trade‑Off: Slight Runtime Overhead for Safety
Storing an error tag alongside a value means each error‑returning function allocates a few bytes more on the stack. In tight loops that call a function thousands of times, this overhead can add up. However, in typical I/O‑heavy code the cost is negligible compared to disk latency. If you measure a performance‑critical path and find the overhead significant, you can opt for a plain return value and manually handle errors, but you lose the compile‑time safety guarantees.
Practical Checklist Before You Ship
- Compile the example:
zig build-exe example.zigand run it to see the error handling in action. - Run
zig fmt -d example.zigto ensure consistent formatting. - If you call a C library that returns
errnoorNULL, wrap the call in a Zig function that translates those failures into your error set. - Use
tryonly when you want to propagate the error; usecatchwhen you need to handle it locally.
By adopting error unions, you trade a small, predictable runtime cost for a codebase that is easier to read, maintain, and, most importantly, free of silent failures.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.