An error API is the part of your function signature that tells callers how failure works.
An error API is the part of your function signature that tells callers how failure works.
In Zig, this is not hidden. A function that can fail shows that fact in its return type:
fn loadConfig() !Config {
// ...
}The !Config return type says:
this function returns either an error or a ConfigThat is already an API decision. You are telling callers that loading a config is not guaranteed to succeed.
But good error API design goes further. It asks:
What errors should callers see?
Which errors are implementation details?
Should errors be precise or broad?
Should this function return an error, an optional, or a normal value?
These choices affect how easy your code is to use.
Start with the Caller
Do not design errors only from the inside of the function.
Design them from the caller’s point of view.
Suppose you write:
fn loadConfig() !Config {
// ...
}A caller wants to know:
Can the file be missing?
Can the syntax be invalid?
Can memory allocation fail?
Can permission be denied?
Can the config version be unsupported?If the caller needs to react differently to those cases, your API should expose them clearly.
For example:
const ConfigError = error{
FileNotFound,
PermissionDenied,
InvalidSyntax,
UnsupportedVersion,
OutOfMemory,
};
fn loadConfig(allocator: std.mem.Allocator) ConfigError!Config {
// ...
}Now the caller has useful information.
Prefer Meaningful Error Names
Good error names describe what went wrong.
Weak names:
error.Failed
error.Bad
error.Unknown
error.ErrorThese names do not help the caller decide what to do.
Better names:
error.FileNotFound
error.PermissionDenied
error.InvalidSyntax
error.MissingRequiredField
error.UnsupportedVersionThese names are concrete. They point to a real condition.
A good error name should make this kind of code readable:
const config = loadConfig(allocator) catch |err| switch (err) {
error.FileNotFound => try createDefaultConfig(),
error.InvalidSyntax => return error.ConfigIsInvalid,
error.PermissionDenied => return error.CannotReadConfig,
else => return err,
};The error names explain the policy.
Use Explicit Error Sets for Public APIs
For small private helpers, inferred error sets are often fine:
fn helper() !void {
try stepOne();
try stepTwo();
}But for public APIs, an explicit error set is usually better:
const ParseError = error{
EmptyInput,
InvalidCharacter,
UnterminatedString,
};
pub fn parse(text: []const u8) ParseError!Ast {
// ...
}This makes the function contract clear.
The caller can see the exact failure cases without reading the implementation.
Keep Internal Errors Internal
A function may call lower-level code that returns detailed errors.
That does not mean all those errors should leak into your public API.
For example:
fn readConfigText(allocator: std.mem.Allocator) ![]u8 {
return try std.fs.cwd().readFileAlloc(
allocator,
"config.json",
1024 * 1024,
);
}This function may return file system errors and allocation errors.
But a higher-level API might want a simpler contract:
const LoadConfigError = error{
CannotReadConfig,
InvalidConfig,
OutOfMemory,
};
fn loadConfig(allocator: std.mem.Allocator) LoadConfigError!Config {
const text = readConfigText(allocator) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
else => return error.CannotReadConfig,
};
defer allocator.free(text);
return parseConfig(text) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
else => return error.InvalidConfig,
};
}Here, the lower-level details are mapped into a smaller public error set.
Callers of loadConfig do not need to know every file system error. They only need to know that the config could not be read.
Do Not Hide Important Errors
Simplifying errors can be good, but do not erase information callers need.
This might be too vague:
const LoadError = error{
Failed,
};Now every failure becomes error.Failed.
The caller cannot tell whether the file is missing, unreadable, invalid, or unsupported.
A better design gives useful categories:
const LoadError = error{
FileNotFound,
CannotReadFile,
InvalidSyntax,
UnsupportedVersion,
OutOfMemory,
};This is still not every low-level detail, but it gives callers real choices.
Use Optional for Normal Absence
Do not use errors when there is no failure.
For example, searching a list may simply find nothing.
fn findUser(users: []const User, id: u64) ?User {
for (users) |user| {
if (user.id == id) return user;
}
return null;
}This is better than:
fn findUser(users: []const User, id: u64) !User {
return error.NotFound;
}Use an optional when absence is a normal result.
Use an error when something failed.
Combine Error Union and Optional When Needed
Sometimes both ideas are needed.
fn findUserInDatabase(id: u64) !?User {
// ...
}This means:
error: the database query failed
null: the query succeeded, but no user was found
User: the query succeeded, and the user was foundThis is more precise than treating all missing users as errors.
A caller can handle the cases separately:
const maybe_user = try findUserInDatabase(42);
if (maybe_user) |user| {
try showUser(user);
} else {
try showNotFoundMessage();
}First, try handles real failure.
Then, if handles ordinary absence.
Include OutOfMemory Honestly
If a function allocates memory, allocation can fail.
That usually means error.OutOfMemory belongs in the error set.
const BuildError = error{
OutOfMemory,
InvalidInput,
};
fn buildMessage(allocator: std.mem.Allocator, text: []const u8) BuildError![]u8 {
if (text.len == 0) return error.InvalidInput;
return try allocator.dupe(u8, text);
}Do not pretend allocation cannot fail unless the allocator or context truly guarantees it.
Zig makes allocation explicit, so error APIs should reflect allocation failure honestly.
Decide Where Policy Belongs
Low-level code should usually report errors.
High-level code should usually decide policy.
Low-level function:
fn parsePort(text: []const u8) !u16 {
return try std.fmt.parseInt(u16, text, 10);
}High-level policy:
const port = parsePort(text) catch 8080;The parser should not decide that 8080 is the default. The application should decide that.
This keeps reusable code clean.
Avoid anyerror in Stable Interfaces
anyerror means any possible error.
fn loadConfig() anyerror!Config {
// ...
}This is flexible, but broad.
It tells the caller:
anything might happenThat weakens the function contract.
For private code or quick programs, anyerror may be acceptable. For stable library APIs, prefer a named error set.
const LoadConfigError = error{
FileNotFound,
PermissionDenied,
InvalidSyntax,
OutOfMemory,
};
fn loadConfig(allocator: std.mem.Allocator) LoadConfigError!Config {
// ...
}This is easier for callers to handle correctly.
Keep Error Sets Small Enough to Understand
An error set should be useful, not enormous.
Too small:
const Error = error{
Failed,
};Too broad:
const Error = error{
FileNotFound,
PermissionDenied,
PathTooLong,
NameTooLong,
DeviceBusy,
DiskQuota,
NoSpaceLeft,
InvalidSyntax,
InvalidEscape,
InvalidNumber,
MissingField,
DuplicateField,
UnsupportedVersion,
UnknownSection,
OutOfMemory,
Timeout,
Interrupted,
WouldBlock,
};The right size depends on the caller.
If callers can act differently on each case, keep them separate.
If callers will always handle several cases the same way, grouping them may be better.
Document Ownership with Errors
When a function returns allocated memory, its error behavior and ownership behavior should be clear.
fn readFileAlloc(
allocator: std.mem.Allocator,
path: []const u8,
) ![]u8 {
// caller owns returned memory on success
}The rule should be:
on success, caller owns the returned value
on error, the function cleans up its partial workInside the function, errdefer helps enforce this:
fn makePair(allocator: std.mem.Allocator) !Pair {
const left = try allocator.alloc(u8, 100);
errdefer allocator.free(left);
const right = try allocator.alloc(u8, 100);
errdefer allocator.free(right);
return Pair{
.left = left,
.right = right,
};
}If the second allocation fails, the first allocation is freed.
If the function succeeds, ownership moves to the caller.
A Practical Checklist
When designing a Zig error API, ask these questions:
| Question | Good Design Pressure |
|---|---|
| Can this operation fail? | Use an error union. |
| Is absence normal? | Use an optional. |
| Can both failure and absence happen? | Use !?T. |
| Can the caller act differently on different failures? | Use precise error names. |
| Are lower-level errors too detailed? | Map them to a cleaner error set. |
| Does the function allocate? | Include OutOfMemory when appropriate. |
| Is this public API? | Prefer an explicit named error set. |
| Does the function return ownership on success? | Use errdefer for partial cleanup. |
The Core Idea
Designing error APIs means designing how failure appears to callers.
A good Zig error API is honest, precise, and not more complicated than necessary.
It should tell the caller:
this operation can fail;
these are the failures you may need to handle;
on success, this is what you receive;
on error, partial work has been cleaned upThat makes error handling part of the program’s structure, not an afterthought.