Error Union Internals
An error union is a value that can contain either:
Error Union Internals
An error union is a value that can contain either:
an error
or:
a successful value
You have already seen this shape:
fn readNumber() !i32 {
return 42;
}
The return type is:
!i32
This means:
either an error, or an i32
You can also write the error set explicitly:
fn readNumber() error{InvalidInput}!i32 {
return 42;
}
This means:
either error.InvalidInput, or an i32
The short form:
!i32
lets Zig infer the error set.
Error Union as a Type
An error union combines two parts:
ErrorSet!Payload
For example:
error{NotFound}!usize
The error set is:
error{NotFound}
The payload is:
usize
So the full type means:
either error.NotFound, or a usize
This is similar in spirit to an optional type:
?usize
But they mean different things.
An optional says:
maybe a value, maybe null
An error union says:
maybe a value, maybe a named error
null gives no reason. An error gives a reason.
A Simple Example
const std = @import("std");
const ParseError = error{
Empty,
InvalidDigit,
};
fn parseOneDigit(text: []const u8) ParseError!u8 {
if (text.len == 0) {
return error.Empty;
}
const c = text[0];
if (c < '0' or c > '9') {
return error.InvalidDigit;
}
return c - '0';
}
pub fn main() void {
const result = parseOneDigit("7");
if (result) |value| {
std.debug.print("value: {}\n", .{value});
} else |err| {
std.debug.print("error: {}\n", .{err});
}
}
The function return type is:
ParseError!u8
That means the function can return:
error.Empty
error.InvalidDigit
or a successful u8.
Success and Failure Are One Value
This line does not immediately give you a u8:
const result = parseOneDigit("7");
The type of result is:
ParseError!u8
It is still an error union.
You must unwrap it before using the u8.
if (result) |value| {
// value is u8 here
} else |err| {
// err is ParseError here
}
Inside the success block, value is the payload.
Inside the error block, err is the error.
try Unwraps the Success Value
Most Zig code uses try with error unions.
const value = try parseOneDigit("7");
This means:
If parseOneDigit succeeds, put the u8 into value.
If it returns an error, return that error from the current function.
So this:
fn parseTwoDigits(text: []const u8) ParseError!u8 {
const first = try parseOneDigit(text[0..1]);
const second = try parseOneDigit(text[1..2]);
return first * 10 + second;
}
is a compact form of explicit error handling.
The important internal idea is that try does not make the error disappear. It either unwraps the success value or propagates the error.
catch Handles the Error Value
Use catch when you want to handle an error locally.
const value = parseOneDigit("x") catch |err| {
std.debug.print("could not parse digit: {}\n", .{err});
return;
};
If parsing succeeds, value is a u8.
If parsing fails, the catch block receives the error.
You can also provide a default value:
const value = parseOneDigit("x") catch 0;
This means:
Use the parsed digit if parsing succeeds.
Use 0 if parsing fails.
Use this only when a default really makes sense.
Error Sets Are Types
This is an error set:
const FileError = error{
NotFound,
PermissionDenied,
TooLarge,
};
It is a type.
Values of this type are written like this:
error.NotFound
error.PermissionDenied
error.TooLarge
A function can return one of those errors:
fn openConfig() FileError!void {
return error.NotFound;
}
The return type says exactly which errors this function can return.
That is part of the function’s contract.
Inferred Error Sets
You can let Zig infer the error set:
fn openConfig() !void {
return error.NotFound;
}
Here, Zig can infer that the function may return error.NotFound.
This is convenient, but explicit error sets can be clearer in public APIs.
For internal helper functions, inferred error sets are often fine.
For library functions, explicit error sets often document the API better.
Error Set Coercion
A smaller error set can often be used where a larger error set is expected.
const SmallError = error{
NotFound,
};
const BigError = error{
NotFound,
PermissionDenied,
};
fn small() SmallError!void {
return error.NotFound;
}
fn big() BigError!void {
return try small();
}
This works because every SmallError is also part of BigError.
The opposite direction is not safe:
fn returnsBig() BigError!void {
return error.PermissionDenied;
}
fn returnsSmall() SmallError!void {
return try returnsBig(); // error
}
returnsBig might return error.PermissionDenied, which is not in SmallError.
The compiler protects the declared error contract.
Error Union vs Optional
Use an optional when absence is enough information.
fn findIndex(items: []const u8, target: u8) ?usize {
for (items, 0..) |item, i| {
if (item == target) return i;
}
return null;
}
If the item is not found, null is enough.
Use an error union when the caller needs to know what went wrong.
const LoadError = error{
NotFound,
PermissionDenied,
InvalidFormat,
};
fn loadConfig(path: []const u8) LoadError!Config {
// ...
}
Here, failure has different meanings. The caller may handle them differently.
Error Union vs Error Set Alone
These two types are different:
error{NotFound}
and:
error{NotFound}!usize
The first is only an error value.
The second is either an error or a usize.
Example:
const e: error{NotFound} = error.NotFound;
This stores only an error.
const x: error{NotFound}!usize = 10;
This stores a successful usize.
const y: error{NotFound}!usize = error.NotFound;
This stores an error.
Payload Type Can Be void
Many functions can fail but do not return a useful success value.
Their type often looks like this:
!void
or:
error{Failed}!void
This means:
either an error, or successful completion
Example:
fn saveFile() !void {
// save the file
}
Calling it:
try saveFile();
There is no value to store. Success simply means the function completed.
Error Names Are Global
In Zig, error names are global by name.
If two error sets both contain NotFound, they refer to the same error name.
const FileError = error{
NotFound,
};
const UserError = error{
NotFound,
};
Both contain:
error.NotFound
This makes error set coercion possible, but it also means you should choose clear error names.
Sometimes a more specific name is better:
error.FileNotFound
error.UserNotFound
Use names that make sense in the API.
Error Unions Are Not Exceptions
An error union is a normal value.
It is visible in the type.
This function says it can fail:
fn connect() !Connection
This function says it cannot fail:
fn port() u16
There is no hidden exception path. You can see possible failure in the function signature.
That is one of Zig’s core design choices.
What “Internals” Means for Beginners
At this stage, you do not need to know the exact memory layout of every error union.
The useful mental model is this:
An error union is a tagged result:
one case is an error,
the other case is a success value.
So when you see:
!T
read it as:
error or T
When you see:
E!T
read it as:
one of the errors in E, or T
Then choose how to handle it:
try value
to propagate errors,
value catch ...
to handle errors,
or:
if (value) |success| {
...
} else |err| {
...
}
to handle both cases explicitly.
The Main Idea
An error union is Zig’s explicit way to represent fallible computation.
error{InvalidInput}!u8
means:
This computation can produce InvalidInput, or it can produce a u8.
The compiler makes you respect that type. You cannot quietly treat a fallible result as a normal value.
That is the point.
Failure is part of the type, so failure is part of the program’s design.