try
try is the most common way to handle errors in Zig.
It means:
call this function;
if it succeeds, give me the success value;
if it fails, return the error from the current functionSo try does two jobs:
It unwraps the success value.
It propagates the error upward.
The Basic Shape
Suppose we have this function:
const ParseError = error{
InvalidDigit,
};
fn parseDigit(c: u8) ParseError!u8 {
if (c < '0' or c > '9') {
return error.InvalidDigit;
}
return c - '0';
}The return type is:
ParseError!u8That means:
either a ParseError,
or a u8Now call it with try:
fn parseTwoDigits(a: u8, b: u8) ParseError!u8 {
const first = try parseDigit(a);
const second = try parseDigit(b);
return first * 10 + second;
}This line:
const first = try parseDigit(a);means:
if parseDigit(a) succeeds, store the u8 in first
if parseDigit(a) fails, return that error from parseTwoDigitsAfter try, first is a plain u8. It is no longer an error union.
try Requires the Current Function to Return an Error
This is important.
Because try may return an error from the current function, the current function must be allowed to return errors.
This works:
fn run() !void {
try doWork();
}This does not work:
fn run() void {
try doWork();
}The second version says run returns void, not !void. So where would the error go?
If a function uses try, its return type usually needs !.
fn run() !void {
try doWork();
}Read this as:
run either succeeds with no value,
or returns an errortry Is Not Magic
try is short syntax for a common catch pattern.
This:
const value = try parseDigit(c);means roughly:
const value = parseDigit(c) catch |err| return err;So try does not hide error handling. It expresses a common rule:
I do not handle this error here.
Send it to my caller.This is useful because many functions are not the right place to decide what to do about an error.
For example, a low-level file-reading function should not always print an error message or exit the program. It should often return the error to a higher-level function that knows the context.
A File Example
Here is a function that reads a file into memory:
const std = @import("std");
fn readTextFile(
allocator: std.mem.Allocator,
path: []const u8,
) ![]u8 {
const file = try std.fs.cwd().openFile(path, .{});
defer file.close();
return try file.readToEndAlloc(allocator, 1024 * 1024);
}There are two try expressions.
const file = try std.fs.cwd().openFile(path, .{});Opening the file can fail. The file may not exist. The program may not have permission. The path may be invalid.
If opening succeeds, file becomes a normal file value.
If opening fails, the function returns the error immediately.
The second try is here:
return try file.readToEndAlloc(allocator, 1024 * 1024);Reading can also fail. Allocation can fail. The file operation can fail.
If it succeeds, the function returns the byte slice.
If it fails, the function returns the error.
try Preserves the Original Error
When try propagates an error, it sends the same error upward.
Example:
fn inner() !void {
return error.NotFound;
}
fn outer() !void {
try inner();
}If inner returns error.NotFound, then outer also returns error.NotFound.
The error is not converted automatically into a string or exception object. It remains an error value.
Error Sets Must Fit
When you use try, the error from the called function must fit into the error set of the current function.
This works:
const InnerError = error{
NotFound,
};
const OuterError = error{
NotFound,
PermissionDenied,
};
fn inner() InnerError!void {
return error.NotFound;
}
fn outer() OuterError!void {
try inner();
}inner can return NotFound.
outer can also return NotFound.
So try inner(); is valid.
But this does not work:
const InnerError = error{
NotFound,
PermissionDenied,
};
const OuterError = error{
NotFound,
};
fn inner() InnerError!void {
return error.PermissionDenied;
}
fn outer() OuterError!void {
try inner();
}inner may return PermissionDenied, but outer only promises NotFound.
Zig rejects that because outer would be returning an error outside its declared contract.
You can fix it by widening the outer error set:
const OuterError = error{
NotFound,
PermissionDenied,
};Or by handling the extra error locally with catch.
try Can Be Used Inside Expressions
try is an expression. That means it can appear where a value is needed.
Example:
const total = (try parseDigit('4')) + (try parseDigit('5'));This works because each try parseDigit(...) produces a u8 if it succeeds.
But for readability, beginners should usually split this into separate lines:
const a = try parseDigit('4');
const b = try parseDigit('5');
const total = a + b;This is easier to debug and easier to explain.
try with void
Many error-returning functions have success type void.
fn save() !void {
// may fail
}Calling this with try looks like this:
try save();There is no value to store. If save succeeds, execution continues. If it fails, the error is returned from the current function.
This pattern is common:
fn run() !void {
try connect();
try sendRequest();
try readResponse();
}Read it as a sequence of steps.
If any step fails, run stops and returns the error.
try and Cleanup
When try returns early, defer still runs.
Example:
fn useFile() !void {
const file = try std.fs.cwd().openFile("data.txt", .{});
defer file.close();
try processFile(file);
}If processFile(file) fails, the function returns early.
But before it returns, this still runs:
defer file.close();That is why try works well with defer. You can write cleanup once, then allow errors to propagate safely.
try Should Not Replace Thinking
try is convenient, but you should not use it blindly.
Use try when the current function cannot make a good decision about the error.
Handle the error locally when the current function does know what to do.
For example, this is a good use of try:
fn loadConfig(allocator: std.mem.Allocator) !Config {
const text = try readTextFile(allocator, "config.json");
defer allocator.free(text);
return try parseConfig(text);
}loadConfig does not know how the whole application wants to respond to failure. It returns the error upward.
But this may be better with catch:
const port = parsePort(text) catch 8080;Here, the program has a clear fallback. If the port is invalid, use a default.
The Core Idea
try means:
unwrap the success value,
or return the error from this functionIt is Zig’s clean syntax for error propagation.
Use try when the current function should not handle the error itself. Use catch when the current function should decide what happens next.