← History

Error handling without exceptions: codes, error unions, and defer

A tour of how C, C++, Zig, Odin, Hare, and Forth signal and handle failure without leaning on exceptions, and how each one guarantees that memory is freed on the error path as well as the happy one.

CC++ZigOdinHare

An exception is a hidden second return path. It lets a function fail without the caller writing a single line to check for it, and it unwinds the stack until someone catches it. That convenience is exactly what most systems languages refuse. Their shared bet is that errors are just values: a function that can fail says so in its type, the caller has to look at the result, and control flow stays visible on the page. This article compares how six of them do it, and it keeps one question in view the whole time, because it is the question this site is built around: when a function bails out early, who frees the memory it already allocated? An early return is where leaks are born.

C: sentinels, errno, and the goto ladder

C has no exceptions and no destructors. A function reports failure by returning a value the caller agrees to treat as bad: a null pointer from malloc, -1 from read, a nonzero status, or the global errno set as a side effect. The discipline is unglamorous and total. You check every call, because a return you forget to test is a bug that compiles cleanly.

The memory problem appears the moment a function holds more than one resource. Each early exit has to release everything acquired so far, in reverse order, and nothing else. The idiom that keeps this correct is the goto cleanup ladder:

int load(const char *path) {
    int rc = -1;
    char *buf = malloc(4096);
    if (!buf) goto out;
    FILE *f = fopen(path, "rb");
    if (!f) goto free_buf;
    if (fread(buf, 1, 4096, f) == 0) goto close_f;
    rc = 0;
close_f: fclose(f);
free_buf: free(buf);
out: return rc;
}

Each label enters the ladder at the right rung, so a failure after fopen closes the file and frees the buffer, while a failure before it frees only the buffer. It is manual, but it is honest. See manual memory management for why C leans on this pattern so heavily.

C++: the outlier that kept exceptions

C++ is the one language here that embraces exceptions, and it can afford to because of RAII: a resource lives in an object whose destructor releases it, and destructors run automatically as the stack unwinds. Throw an exception through a scope full of std::vector, std::unique_ptr, and std::lock_guard, and every one of them is cleaned up on the way out with no catch block in sight. That is the deep reason RAII and exceptions belong together. The destructor is the cleanup ladder, written once and run on every path, including the thrown one. The RAII, defer, and manual cleanup article compares the three strategies directly.

Modern implementations use a "zero-overhead" model: the non-throwing path costs nothing, no per-try setup, but an actual throw is expensive and walks tables to find the handler. That asymmetry is why whole industries ban exceptions. Games, embedded, and latency-sensitive code compile with -fno-exceptions because they cannot pay the unpredictable cost of a throw or the code-size cost of the unwind tables, and writing genuinely exception-safe code is hard. Those codebases fall back on the error-code tradition, and since C++23 on std::expected<T, E>, which carries either a value or an error in the return type. Errors as values, expressed in the C++ type system.

Zig: error sets and error unions

Zig has no exceptions and no hidden control flow. A fallible function returns an error union, written !T, meaning "a T or an error." The caller cannot ignore it. try expr is sugar for "if it is an error, return that error to my caller, otherwise unwrap the value," and catch handles it inline:

fn load(a: std.mem.Allocator) !void {
    const buf = try a.alloc(u8, 4096);
    errdefer a.free(buf);
    const f = try std.fs.cwd().openFile("data", .{});
    defer f.close();
    _ = try f.readAll(buf);
    a.free(buf);
}

The memory story is defer and errdefer. defer runs at scope exit no matter what; errdefer runs only if the function leaves through an error after that point. Here the file is always closed, and the buffer is freed on the error path by errdefer but on success by the explicit free. Deferred statements run last-in-first-out, so cleanup unwinds in reverse acquisition order for free.

Odin: multiple returns and or_return

Odin returns errors as an extra value, usually an enum or union whose zero value means success. You could write if err != nil after every call, but Odin gives you or_return to propagate and or_else to supply a fallback:

load :: proc(path: string) -> Error {
    data := read_file(path) or_return   // on error, return it up
    defer delete(data)
    cfg := parse(data) or_return
    _ = cfg
    return .None
}

or_return checks the trailing error; if it is non-zero it returns from the enclosing procedure with that error, so a failing parse leaves load after defer delete(data) has been registered and the buffer is freed. or_else instead yields a default, as in port := to_int(s) or_else 8080. Odin's defer is the same LIFO scope-exit tool as Zig's.

Hare: errors are tagged unions

Hare makes errors ordinary members of a tagged union. A fallible function returns something like (io::file | fs::error), and error types are flagged in their declaration with a leading !. Two operators act on the result: ? propagates the error to the caller, and ! asserts that it cannot happen and aborts the program if it does. match recovers explicitly.

fn load(path: str) (void | fs::error) = {
	const f = os::open(path)?;   // propagate on error
	defer io::close(f)!;
	// ... use f ...
};

defer runs at scope exit in reverse order, so the file closes whether the function returns normally or bails out through a ?. As in C there is no garbage collector, so defer does the work RAII would do in C++.

Forth and HolyC

Classic Forth has no exceptions, but ANS Forth (1994) standardized CATCH and THROW for a nonlocal exit. CATCH takes an execution token, records the depth of the data and return stacks, and runs it. If the word calls THROW with a nonzero code, control jumps straight back to the CATCH, the stacks are restored to the saved depth, and the code is left on top. CATCH returns 0 when nothing was thrown:

: run ( -- )
  ['] risky catch ?dup if
    ." error: " . cr   \ handle the thrown code
  then ;

Because only the stack depth is restored, not the heap, any buffers a word allocated before throwing are still yours to free by hand. HolyC keeps things plain: it is a C-like language JIT-compiled inside TempleOS, and it follows C-style return-code and sentinel conventions for reporting failure, with manual cleanup on every path just like C.

The common thread

Strip away the syntax and every approach here answers the same memory question. C answers it with a goto ladder, C++ with destructors, Zig with errdefer, Odin and Hare with defer, Forth by hand after a catch. Exceptions hide the failure path; these languages insist that you can see it, and, more importantly for a manual-memory language, that you free what you allocated along it.