Zig Error Traces
An error trace is a record of the calls an error passed through before it was handled. This lesson shows how to read its depth without printing anything that changes between runs.
Before, during, after
Before any error the index is zero, inside the handler it is one, and after handling it is zero again.
const std = @import("std");
const TraceError = error{ Boom };
fn boom() TraceError!void {
return TraceError.Boom;
}
pub fn main() !void {
const out = std.io.getStdOut().writer();
if (@errorReturnTrace()) |t| {
try out.print("before: {d}\n", .{t.index});
}
boom() catch {
if (@errorReturnTrace()) |t| {
try out.print("in catch: {d}\n", .{t.index});
}
};
if (@errorReturnTrace()) |t| {
try out.print("after: {d}\n", .{t.index});
}
}
Depth grows with layers
Each added layer adds one to the index.
const std = @import("std");
const TraceError = error{ Boom };
fn one() TraceError!void {
return TraceError.Boom;
}
fn two() TraceError!void {
try one();
}
fn three() TraceError!void {
try two();
}
pub fn main() !void {
const out = std.io.getStdOut().writer();
one() catch {
if (@errorReturnTrace()) |t| {
try out.print("depth 1: {d}\n", .{t.index});
}
};
two() catch {
if (@errorReturnTrace()) |t| {
try out.print("depth 2: {d}\n", .{t.index});
}
};
three() catch {
if (@errorReturnTrace()) |t| {
try out.print("depth 3: {d}\n", .{t.index});
}
};
}
Deep versus shallow
A three-layer chain reports a depth of three, while a direct call reports one.
const std = @import("std");
const TraceError = error{ Boom };
fn read() TraceError!void {
return TraceError.Boom;
}
fn parse() TraceError!void {
try read();
}
fn load() TraceError!void {
try parse();
}
pub fn main() !void {
const out = std.io.getStdOut().writer();
load() catch |err| {
try out.print("error: {s}\n", .{@errorName(err)});
if (@errorReturnTrace()) |t| {
try out.print("trace depth: {d}\n", .{t.index});
}
};
read() catch {
if (@errorReturnTrace()) |t| {
try out.print("shallow depth: {d}\n", .{t.index});
}
};
}
Example
const std = @import("std");
const PipeError = error{ NotFound, BadData };
fn read(ok: bool) PipeError!i32 {
if (ok) return 8;
return PipeError.NotFound;
}
fn parse(ok: bool) PipeError!i32 {
const v = try read(ok);
if (v == 0) return PipeError.BadData;
return v * 2;
}
fn run(ok: bool) PipeError!i32 {
return try parse(ok);
}
pub fn main() !void {
const out = std.io.getStdOut().writer();
if (run(true)) |v| {
try out.print("result: {d}\n", .{v});
} else |err| {
try out.print("failed: {s}\n", .{@errorName(err)});
}
if (run(false)) |v| {
try out.print("result: {d}\n", .{v});
} else |err| {
try out.print("failed: {s}\n", .{@errorName(err)});
if (@errorReturnTrace()) |t| {
try out.print("trace depth: {d}\n", .{t.index});
}
}
}
Key points
- Error trace: an error trace is a record of the calls an error passed through on its way to a handler.
- Error trace function: an error trace function is @errorReturnTrace, a built-in function that returns the current error trace.
- Trace index: a trace index is the count of calls recorded in the trace, and it grows with the depth of the call chain.
- Presence alone proves nothing, because the trace value exists even before any error, with an index of zero.
- The index is most useful inside a handler, while the error is still travelling, since it resets after handling.
- A trace holds return addresses, and addresses change from run to run, so print only the index, never an address.
run calls parse, which calls read, so a failure travels through three layers. The handler prints the error name and the trace depth together. That pair tells the reader what failed and how deep the failure started, without printing any address.
Try it Yourself
Edit the code, then press Run.
Running is simulated
Exercise
A chain of three functions returns an error to main. What trace index does the handler in main see?