Skip to content

Optionals Versus Errors

Optionals and errors both describe a value that may not be produced.

Optionals and errors both describe a value that may not be produced.

They mean different things.

An optional means absence.

?usize

This says:

there may be a usize, or there may be no value

An error union means failure.

error{Invalid}!usize

This says:

there may be a usize, or the operation may fail with Invalid

Use an optional when no value is a normal result.

Use an error when the operation could not be completed.

Searching a slice is a good optional case:

fn indexOfByte(buf: []const u8, target: u8) ?usize {
    for (buf, 0..) |b, i| {
        if (b == target) {
            return i;
        }
    }

    return null;
}

If the byte is not found, nothing went wrong. The answer is simply absent.

Opening a file is an error case:

const std = @import("std");

pub fn main() !void {
    const file = try std.fs.cwd().openFile("input.txt", .{});
    defer file.close();

    _ = file;
}

If the file cannot be opened, there is a reason. The path may not exist. Permission may be denied. The operation may exceed an OS limit.

That is not mere absence. It is failure.

The distinction keeps programs honest.

A function returning ?T gives the caller one question:

if (value) |v| {
    // use v
} else {
    // no value
}

A function returning E!T gives the caller another question:

const v = operation() catch |err| {
    // handle err
};

When both are possible, the types can be combined.

fn findConfig(name: []const u8) !?[]const u8 {
    _ = name;

    // May fail while reading.
    // May also succeed and find nothing.
    return null;
}

The return type is:

!?[]const u8

Read it as:

the operation may fail; if it succeeds, it may return a slice or no slice

Use it like this:

const result = try findConfig("port");

if (result) |value| {
    std.debug.print("found: {s}\n", .{value});
} else {
    std.debug.print("not found\n", .{});
}

The try handles the error part.

The if handles the optional part.

These two checks should not be collapsed into one vague result. Zig gives each case its own type.

A bad design uses null for everything:

fn readFileMaybe(path: []const u8) ?[]u8 {
    _ = path;
    return null;
}

The caller cannot tell whether the file did not exist, permission was denied, memory ran out, or the file was empty.

A better design uses an error union:

fn readFile(path: []const u8) ![]u8 {
    _ = path;
    return error.NotImplemented;
}

Now failure is visible.

Optionals are for missing values.

Errors are for failed operations.

Exercise 9-14. Decide whether each function should return ?T or !T: search in an array, open a file, parse an integer, find a field in a struct-like table.

Exercise 9-15. Write a function findFirstLongWord that returns ?[]const u8.

Exercise 9-16. Write a function type for loading a config value from disk where reading may fail and the key may be absent.