Minimal Viable Zig Error Contexts

fn process_file(io: Io, path: []const u8) !void {
    errdefer log.err("path={s}", .{path});

    const fd = try Io.Dir.cwd().openFile(io, path, .{});
    defer fd.close(io);

    // ...
}

Out of the box, Zig provides minimal and sufficient facilities for error handlingstrongly-typed error codes. Error reporting is left to the user. Idiomatic solution is to pass a Diagnostics out parameter (“sink”) to materialize human-readable strings as needed.

Diagnostics pattern works well for “production” code, but for more script-y code it adds too much friction relative to the default option of a plain try fallible(), which of course gives a less than ideal message on failure:

λ zig build
error: FileNotFound
~/.cache/zig/p/../lib/std/Io/Threaded.zig:4866:35: 0x1044126c7 in dirOpenFilePosix (fail)
                        .NOENT => return error.FileNotFound,
                                  ^
~/.cache/zig/p/../lib/std/Io/Dir.zig:578:5: 0x104347d8b in openFile (fail)
    return io.vtable.dirOpenFile(io.userdata, dir, sub_path, options);
    ^
~/fail/main.zig:10:16: 0x10443da5f in f (fail)
    const fd = try Io.Dir.cwd().openFile(io, path, .{});
               ^
~/fail/main.zig:6:5: 0x10443db47 in main (fail)
    try process_file(io, "data.txt");
    ^

Error trace is helpful, but knowing which file is the problem is even more so.

The first attempt at finding a middle ground between fully-fledged diagnostics sink pattern and a plain try is something like this:

const fd = dir.openFile(io, path, .{}) catch |err| {
    log.err("failed to open file '{s}': {t}", .{path, err});
    return err;
}

Unsatisfactory. The friction is high, you need to come up with a reasonably-sounding error message, the “happy path” of the code is obscured, and you need to repeat this for every fallible operation.

A worse-is-better version of the above code is

errdefer log.err("path={s}", .{path});
const fd = try dir.openFile(io, path, .{});

That is, just log error context as key=value pairs, guarded by errdefer. The result is not pretty, but passable:

λ zig build
error: path=./data.txt
error: FileNotFound
~/.cache/zig/p/../lib/std/Io/Threaded.zig:4866:35: 0x1044126c7 in dirOpenFilePosix (fail)
                        .NOENT => return error.FileNotFound,
                                  ^
~/.cache/zig/p/../lib/std/Io/Dir.zig:578:5: 0x104347d8b in openFile (fail)
    return io.vtable.dirOpenFile(io.userdata, dir, sub_path, options);
    ^
~/fail/main.zig:10:16: 0x10443da5f in f (fail)
    const fd = try Io.Dir.cwd().openFile(io, path, .{});
               ^
~/fail/main.zig:6:5: 0x10443db47 in main (fail)
    try process_file(io, "data.txt");
    ^

The friction is reduced a lot:

There’s one huge drawback though — the error message is logged, even if the error is subsequently handled. This is especially important in Zig 0.16, where cancelation (serendipitous-success) is a possible error for any IO-ing operation, and which is intended to be handled, rather than reported.


Generalizing:

This does feel like a better error management strategy than decorating errors individually, when they happen. I wonder which language features facilitate this style?