Skip to content

Returning Errors

A function returns an error the same way it returns a normal value: with return.

A function returns an error the same way it returns a normal value: with return.

fn fail() !void {
    return error.Failed;
}

The return type is !void, so the function may return either void or an error.

A more useful example:

const std = @import("std");

fn checkName(name: []const u8) !void {
    if (name.len == 0) {
        return error.EmptyName;
    }

    if (name.len > 32) {
        return error.NameTooLong;
    }
}

pub fn main() !void {
    try checkName("zig");
    std.debug.print("name is valid\n", .{});
}

checkName has no successful value to return. It only checks the input. If the input is good, it reaches the end of the function and returns void.

If the input is bad, it returns an error.

return error.EmptyName;

or:

return error.NameTooLong;

The caller may propagate the error:

try checkName("zig");

or handle it:

checkName("") catch |err| {
    std.debug.print("bad name: {}\n", .{err});
    return;
};

Errors can be returned from inside branches.

fn firstByte(s: []const u8) !u8 {
    if (s.len == 0) {
        return error.EmptySlice;
    }

    return s[0];
}

This function returns a byte when the slice has one. It returns error.EmptySlice when the slice is empty.

The successful return is:

return s[0];

The failing return is:

return error.EmptySlice;

Both match the return type:

!u8

A function may use an explicit error set.

const NameError = error{
    EmptyName,
    NameTooLong,
};

fn checkName(name: []const u8) NameError!void {
    if (name.len == 0) {
        return NameError.EmptyName;
    }

    if (name.len > 32) {
        return NameError.NameTooLong;
    }
}

Now the signature says exactly which errors may come back.

NameError!void

This is often useful at API boundaries. A caller can read the type and know the failure cases.

Inside small private functions, inferred error sets are often enough:

fn checkName(name: []const u8) !void {
    if (name.len == 0) return error.EmptyName;
    if (name.len > 32) return error.NameTooLong;
}

Both styles are common. Use the explicit form when the set of errors is part of the contract.

Errors can be translated before returning.

const std = @import("std");

fn openConfig() !std.fs.File {
    return std.fs.cwd().openFile("config.txt", .{}) catch |err| switch (err) {
        error.FileNotFound => error.MissingConfig,
        else => err,
    };
}

This function hides the file-system detail FileNotFound and returns a program-level error MissingConfig.

The else => err case returns any other error unchanged.

This pattern is useful when lower-level errors are too specific for the caller. The public function can expose errors in the language of the application.

A function may also clean up before returning an error. For that, Zig uses defer and errdefer. The next sections will cover them more carefully, but the basic idea is simple:

fn makeThing(allocator: std.mem.Allocator) ![]u8 {
    const buf = try allocator.alloc(u8, 1024);
    errdefer allocator.free(buf);

    // more work that may fail

    return buf;
}

If later work fails, errdefer frees the buffer before the error returns.

Returning errors should be direct. Do not hide failure in sentinel values like 0, -1, or an empty string unless those values are truly part of the data. Use an error when the operation failed.

Exercise 8-9. Write firstByte for a slice of bytes.

Exercise 8-10. Write checkAge that returns error.TooYoung if the age is less than 18.

Exercise 8-11. Define an explicit error set for username validation.

Exercise 8-12. Write a function that converts error.FileNotFound into error.MissingConfig.