An error union type means:
either an error,
or a normal valueYou have already seen this shape:
!i32This means:
either an error,
or an i32More explicitly, Zig can also write:
SomeError!i32This means:
either one error from SomeError,
or an i32So an error union joins two things together:
error set + success typeFor example:
const DivideError = error{
DivisionByZero,
};
fn divide(a: i32, b: i32) DivideError!i32 {
if (b == 0) {
return error.DivisionByZero;
}
return a / b;
}The return type is:
DivideError!i32That means the function can produce one of two results:
error.DivisionByZeroor:
an i32 valueThe caller must deal with both possibilities.
Why This Type Exists
Many operations can fail, but still have a useful result when they succeed.
Opening a file can fail, but if it succeeds, you get a file handle.
Parsing a number can fail, but if it succeeds, you get a number.
Allocating memory can fail, but if it succeeds, you get a slice or pointer.
Reading from a socket can fail, but if it succeeds, you get bytes.
That is exactly what error unions model.
They say:
this operation has a success value,
but it may return an error insteadThis is more precise than returning a magic value like -1, null, or false.
A Simple Parse Example
Suppose we want to parse a digit.
const ParseError = error{
InvalidDigit,
};
fn parseDigit(c: u8) ParseError!u8 {
if (c < '0' or c > '9') {
return error.InvalidDigit;
}
return c - '0';
}This function returns ParseError!u8.
So the caller cannot treat the result as a plain u8 immediately.
This is wrong:
const n: u8 = parseDigit('7');The function does not return only a u8. It returns either an error or a u8.
You must unwrap it.
Unwrapping with try
The most common way to unwrap an error union is try.
const n: u8 = try parseDigit('7');This means:
if parseDigit succeeds, put the u8 value into n
if parseDigit fails, return the error from the current functionBecause try can return an error from the current function, the current function must also be allowed to fail.
pub fn main() !void {
const n = try parseDigit('7');
std.debug.print("{}\n", .{n});
}Here, main returns !void, so it can propagate errors.
Unwrapping with catch
Use catch when you want to handle the error immediately.
const n = parseDigit('x') catch 0;This means:
if parseDigit succeeds, use the parsed digit
if parseDigit fails, use 0 insteadSo n becomes 0.
You can also inspect the error:
const n = parseDigit('x') catch |err| {
std.debug.print("parse failed: {}\n", .{err});
return;
};Inside the catch block, err is the error value.
Error Union Values Are Not Plain Values
This is a key beginner mistake.
If a function returns !i32, you do not have an i32 yet.
You have a value that may contain an i32.
So this does not work:
const result = divide(10, 2);
std.debug.print("{}\n", .{result});The variable result has an error union type, not a plain integer type.
You need one of these:
const result = try divide(10, 2);or:
const result = divide(10, 2) catch 0;or:
const result = divide(10, 2) catch |err| {
std.debug.print("division failed: {}\n", .{err});
return;
};Only after unwrapping do you have the success value.
Returning Success and Returning Failure
Inside a function with an error union return type, you can return either kind of value.
fn divide(a: i32, b: i32) DivideError!i32 {
if (b == 0) {
return error.DivisionByZero;
}
return a / b;
}This line returns failure:
return error.DivisionByZero;This line returns success:
return a / b;The return type allows both.
That is why the type is called an error union. It is a union of two possibilities.
Explicit Error Set vs Inferred Error Set
This version names the error set:
fn divide(a: i32, b: i32) DivideError!i32 {This version lets Zig infer it:
fn divide(a: i32, b: i32) !i32 {Both are error unions.
The difference is the error set.
!i32means:
some inferred error set, or i32while:
DivideError!i32means:
DivideError, or i32For teaching examples, !i32 is shorter.
For serious public APIs, an explicit error set often communicates more clearly.
Error Union with void
Many functions can fail but do not return a useful success value.
For those, use:
!voidThis means:
either an error,
or success with no valueExample:
fn saveConfig() !void {
// write file
}If the function succeeds, there is no result to use. Success only means the operation completed.
If it fails, it returns an error.
This shape is common in Zig.
You will see it in functions that write files, create directories, initialize resources, send data, or run setup steps.
Error Union with Slices and Pointers
Error unions are often used with allocated memory.
fn makeBuffer(allocator: std.mem.Allocator) ![]u8 {
return try allocator.alloc(u8, 1024);
}This returns:
![]u8That means:
either allocation failed,
or you get a mutable byte sliceA caller might use it like this:
const buffer = try makeBuffer(allocator);
defer allocator.free(buffer);The try unwraps the slice. After that, buffer is a normal []u8.
The defer makes sure the buffer is freed later.
Error Union with Optionals
Do not confuse error unions with optionals.
An optional says:
there may be no valueAn error union says:
there may be a failureOptional:
?i32means:
null or i32Error union:
!i32means:
error or i32They answer different questions.
Use an optional when absence is normal and does not need a reason.
Use an error union when failure has a reason.
Example optional:
fn findIndex() ?usize {
return null;
}This means no index was found.
Example error union:
fn readFile() ![]u8 {
// may fail because file is missing, permission is denied, etc.
}This means the operation failed for a specific reason.
Combining Error Union and Optional
Sometimes you need both.
fn findUser(id: u64) !?User {
// ...
}Read this carefully:
!?UserIt means:
either an error,
or an optional UserSo there are three possible outcomes:
error: database failed
success: user was found
success: user was not foundThis is useful when “not found” is not an error.
For example, a database failure is an error. But searching for a user and finding no match may be a valid result.
A caller might write:
const maybe_user = try findUser(42);
if (maybe_user) |user| {
std.debug.print("found user: {s}\n", .{user.name});
} else {
std.debug.print("user not found\n", .{});
}First, try handles the error possibility.
Then, if handles the optional possibility.
The Caller Must Choose
When you call a function that returns an error union, you must choose what to do with failure.
You can propagate it:
const value = try parseDigit(c);You can replace it with a fallback:
const value = parseDigit(c) catch 0;You can inspect it:
const value = parseDigit(c) catch |err| switch (err) {
error.InvalidDigit => 0,
};You can also deliberately ignore it, but Zig makes that decision visible.
The key idea is that failure does not disappear.
The Core Idea
An error union type is Zig’s way to put success and failure into one return type.
ErrorSet!Tmeans:
either an error from ErrorSet,
or a value of type TYou cannot use the success value until you unwrap the error union.
That is the discipline Zig wants: before using a result, first decide what happens if the operation failed.