Learn Zig Series (#4) - Error Handling (Zig's Best Feature)
What will I learn
- You will learn why most languages get error handling wrong and how Zig fixes it;
- error unions -- the
!operator that connects error types to success types; tryfor propagating errors up the call chain in two characters;catchfor handling errors locally with default values or blocks;- error sets and how they compose with the
||operator; deferanderrdeferfor cleanup that always runs;- optionals (
?T) for values that might not exist; - when to use errors vs optionals.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Zig 0.14+ distribution (download from ziglang.org);
- The ambition to learn Zig programming.
Difficulty
- Beginner
Curriculum (of the Learn Zig Series):
- Zig Programming Tutorial - ep001 - Intro
- Learn Zig Series (#2) - Hello Zig, Variables and Types
- Learn Zig Series (#3) - Functions and Control Flow
- Learn Zig Series (#4) - Error Handling (Zig's Best Feature) (this post)
Learn Zig Series (#4) - Error Handling (Zig's Best Feature)
Welcome back! In episode #3 we built up the entire control flow toolkit -- functions with explicit parameter and return types, multiple return values via anonymous structs, if-expressions, while loops with continue expressions, for loops with captures and indices, switch as an exhaustive expression, labeled blocks, unreachable, and we put the whole thing together in a Trade Journal program that combined all of it. If you haven't gone through that episode yet, do so before continuing -- this one builds directly on those foundations.
I ended ep003 with a teaser: "Zig has one of the most elegant error handling systems I've seen in any language." Time to back that up.
Every real program has to deal with failure. Files don't open. Network connections drop. Users pass garbage input. Memory runs out. The question isn't whether things go wrong, it's how your language forces (or doesn't force) you to deal with it. And most languages, frankly, get this wrong in one way or another. Not partially wrong, not "it's a tradeoff" wrong -- genuinly, deeply, structurally wrong in ways that produce bugs at scale.
Zig's approach is different. I'd argue it's the best error handling system in any production-ready programming language today. Bold claim, I know. Let me show you why ;-)
Solutions to Episode 3 Exercises
Before we start on new material, let me go through the solutions to last episode's exercises. If you actually typed these out and ran them yourself (and I hope you did!), compare your solutions:
Exercise 1 -- max using if-expression:
fn max(a: f64, b: f64) f64 {
return if (a > b) a else b;
}
Clean. The if-expression returns one of the two values directly. No mutable var result, no intermediate state. If you wrote it with a var and an if-statement, it works too -- but the if-expression version is more idiomatic Zig. Remember from ep003: prefer const over var, prefer expressions over statements.
Exercise 2 -- compound growth:
fn compoundGrowth(principal: f64, rate_pct: f64, periods: u32) f64 {
var value = principal;
var i: u32 = 0;
while (i < periods) : (i += 1) {
value *= (1.0 + rate_pct / 100.0);
}
return value;
}
// compoundGrowth(10000, 5, 12) = $17958.56
The while loop with continue expression : (i += 1) handles the counter. Two var bindings here because we genuinely need mutation -- value accumulates across iterations, i increments.
Exercise 3 -- order type switch:
fn orderType(code: u8) []const u8 {
return switch (code) {
0 => "market", 1 => "limit", 2 => "stop", 3 => "stop-limit", else => "unknown",
};
}
Switch as expression, directly returned. The else catches all 252 remaining u8 values we didn't list.
Exercise 4 -- average from array:
const prices = [_]f64{ 64000, 65200, 63800, 67100, 68400, 66900, 67500 };
var sum: f64 = 0;
for (prices) |p| { sum += p; }
const avg = sum / @as(f64, @floatFromInt(prices.len));
// avg = $66128.57
Exercise 5 -- struct with min/max/avg:
fn analyze(a: f64, b: f64, c: f64, d: f64, e: f64) struct { min: f64, max: f64, avg: f64 } {
const mn = @min(@min(@min(a, b), @min(c, d)), e);
const mx = @max(@max(@max(a, b), @max(c, d)), e);
return .{ .min = mn, .max = mx, .avg = (a + b + c + d + e) / 5.0 };
}
The @min and @max builtins compare two values at a time, so we nest them. Not the prettiest code in the world -- once we cover arrays and slices in a later episode, this gets much cleaner. But it works!
Exercise 6 -- nested for with labeled break:
const matrix = [_][3]u32{ .{ 10, 20, 30 }, .{ 40, 50, 60 }, .{ 70, 80, 90 } };
const target: u32 = 50;
const found = outer: for (matrix, 0..) |row, r| {
for (row, 0..) |val, c| {
if (val == target) {
std.debug.print("Found {d} at row={d}, col={d}\n", .{ target, r, c });
break :outer true;
}
}
} else false;
Labeled break exits the outer loop and the whole for-expression evaluates to true. The else false is what happens if the loop finishes without breaking. No mutable flag variable needed. This is one of those patterns that just feels right once you see it.
Now let's talk about error handling.
The Problem With Other Languages
Before showing you Zig's solution, let me explain what it's solving. Because error handling is one of those things where most programmers don't realize how broken their current tools are until they see something better.
C uses error codes. Functions return -1, NULL, errno, or some other sentinel value to signal failure. The problem? Nothing forces you to check them. You can write int fd = open("file.txt", O_RDONLY); and then immediately start reading from fd without ever checking if open returned -1. The code compiles, it "works" (until it doesn't), and millions of bugs exist in production because someone forgot to check a return value. The entire concept of "defensive programming" in C exists because the language itself provides zero safety.
Java introduced checked exceptions -- and then the Java community spent 20 years arguing about whether they were a good idea. The result? Most Java code catches Exception (the base class) and either ignores it, logs it, or rethrows it as a RuntimeException to bypass the checking. The language tried to enforce error handling and the ecosystem worked around it. Not great.
Python and JavaScript use exceptions. Code that can fail looks identical to code that can't -- there's no visible marker in the function signature telling you "hey, this might blow up." Exceptions can fly through 20 stack frames before someone catches them. Or nobody catches them and the program crashes. If you've been following the Learn Python Series, you know I've used try/except blocks quite a bit. They work, but they require discipline that the language doesn't enforce. You have to know that a function might raise an exception, because nothing in the code tells you.
Go took a different approach: explicit error returns. value, err := someFunction(). Better than exceptions because errors are visible in the code. But Go has _ (the blank identifier), so you can write value, _ := someFunction() and silently discard the error. The language lets you ignore errors, and plenty of Go code does exactly that. Also, every single function call that might fail requires the same three-line if err != nil { return err } boilerplate. Over and over and over. It's verbose, repetitive, and error-prone in its own way -- you might copy-paste the pattern and forget to change the variable name in the return.
Rust does it right with Result<T, E> and the ? operator. Rust is the closest to what Zig does, and I have a lot of respect for Rust's approach. But Rust's error handling is coupled with its ownership system and trait system, which adds complexity. Zig achieves similar safety with less machinery.
So what does Zig do?
Error Unions -- The Core Concept
In Zig, a function that might fail declares this in its return type. There's no way to hide it. The return type tells you exactly what the function can return, including what can go wrong:
const std = @import("std");
const TradeError = error{
InsufficientBalance,
InvalidQuantity,
PriceTooLow,
};
fn executeTrade(balance: f64, price: f64, quantity: f64) TradeError!f64 {
if (quantity <= 0) return TradeError.InvalidQuantity;
if (price <= 0) return TradeError.PriceTooLow;
const cost = price * quantity;
if (cost > balance) return TradeError.InsufficientBalance;
return balance - cost;
}
pub fn main() void {
const result = executeTrade(10000.0, 68000.0, 0.1) catch |err| {
std.debug.print("Trade failed: {}\n", .{err});
return;
};
std.debug.print("Remaining balance: ${d:.2}\n", .{result});
}
Let me break this down piece by piece, because every piece matters:
TradeError is an error set -- think of it as a restricted enum that lists all the things that can go wrong. Not a generic "error" or "exception" -- specific, named failure modes. When you read TradeError, you know exactly what can go wrong: insufficient balance, invalid quantity, or price too low. That's it. Nothing else. Compare this to Python where executeTrade could raise ValueError, TypeError, RuntimeError, KeyError, or literally anything else -- and the function signature tells you none of this.
TradeError!f64 is an error union. The ! operator connects the error type (left) to the success type (right). This is the complete contract: "this function returns either a TradeError or an f64." Two possible outcomes, both explicit in the type. The caller must deal with both possibilities. Not "should." Must. The compiler enforces it.
Returning errors looks the same as returning values. return TradeError.InvalidQuantity; and return balance - cost; use the same return keyword. No special throw or raise syntax. Errors are values, not magic. They flow through the same return pathway as normal results.
catch unwraps the error union. The catch |err| block runs if the function returned an error, with err bound to the specific error value. If the function returned a success value, catch is skipped and result gets the f64. This is clean, explicit, and impossible to accidentally skip.
You cannot ignore the error. If you write const result = executeTrade(10000.0, 68000.0, 0.1); without a catch, the compiler rejects your program. You get a type error -- result would be a TradeError!f64, not an f64, and you can't use an error union where a plain f64 is expected. The compiler literally will not let you pretend the error doesn't exist. Period.
This is the fundamental difference from every approach I described above. C lets you ignore error codes. Java lets you catch-and-ignore exceptions. Python never tells you which exceptions might happen. Go lets you _ away errors. Zig? The compiler says "no." You deal with the error, or your code does not compile. There is no escape hatch.
try -- Error Propagation in Two Characters
Most of the time, when a function you call fails, you want to pass that error up to YOUR caller. "I tried to fetch the price, it failed, so I'm failing too -- here's why." This is called error propagation, and Zig makes it incredibly concise:
const std = @import("std");
const ExchangeError = error{ ApiDown, RateLimited, InvalidPair };
fn fetchPrice(pair: []const u8) ExchangeError!f64 {
if (std.mem.eql(u8, pair, "INVALID/USD")) return ExchangeError.InvalidPair;
return 68423.50;
}
fn getPortfolioValue(btc_qty: f64) ExchangeError!f64 {
const btc_price = try fetchPrice("BTC/USD");
return btc_qty * btc_price;
}
pub fn main() void {
const value = getPortfolioValue(0.5) catch |err| {
std.debug.print("Portfolio error: {}\n", .{err});
return;
};
std.debug.print("Portfolio: ${d:.2}\n", .{value});
}
Output:
Portfolio: $34211.75
The try keyword does one thing: if the expression on its right returns an error, try immediately returns that error from the current function. If it returns a success value, try unwraps it and gives you the value.
try expr is equivalent to expr catch |err| return err. But it's two characters instead of Python's four-line try/except/raise pattern. And unlike Go's three-line if err != nil { return err } boilerplate that you repeat hundreds of times per project, Zig's try is inline. One word. Done.
Look at getPortfolioValue. It calls fetchPrice, which might fail. If it does, try propagates the error to whoever called getPortfolioValue. The error passes through transparently -- getPortfolioValue doesn't need to know or care which error happened. It just passes it along. The function's return type ExchangeError!f64 declares that it might produce those errors, and try is the mechanism that makes them flow.
Notice something important: getPortfolioValue itself never creates any errors. It just propagates errors from fetchPrice. But its return type still declares ExchangeError!f64 because it might return those errors (via try). The type system tracks error flow across function boundaries. The compiler knows exactly which errors can come out of any function at any point in your call chain. No surprises.
catch -- Handling Errors Locally
Sometimes you don't want to propagate. You want to handle the error right where it happens -- provide a default value, try an alternative, log and continue. Zig's catch gives you several ways to do this:
const std = @import("std");
const ParseError = error{InvalidInput};
fn parsePrice(s: []const u8) ParseError!f64 {
if (std.mem.eql(u8, s, "invalid")) return ParseError.InvalidInput;
return 68000.0; // simplified for demonstration
}
pub fn main() void {
// 1. Default value on error
const a = parsePrice("invalid") catch 0.0;
// 2. Catch with error capture and block
const b = parsePrice("hello") catch |err| blk: {
std.debug.print("Parse failed: {}, using fallback\n", .{err});
break :blk 50000.0;
};
// 3. catch unreachable -- "I guarantee this won't error"
const c = parsePrice("68000") catch unreachable;
std.debug.print("a={d:.0} b={d:.0} c={d:.0}\n", .{ a, b, c });
}
Output:
Parse failed: error.InvalidInput, using fallback
a=0 b=50000 c=68000
Three flavors of catch:
catch default_value -- the simplest form. If the expression errors, use this value instead. Clean one-liner. You'll use this when the fallback is obvious and you don't need to log or inspect the error.
catch |err| blk: { ... break :blk value; } -- catch with a block. This uses the labeled block pattern from ep003 (remember those?). The block can do anything -- log the error, try an alternative, compute a fallback. It "returns" a value via break :blk. The |err| capture gives you the specific error that occurred, so you can switch on it, log it, whatever you need.
catch unreachable -- "I know this won't error." Same concept as the unreachable we saw in ep003's switch statements. In debug builds, if it DOES error, you get a panic. In release builds, the compiler assumes you're right and optimizes accordingly. Use this when you can prove the error path is impossible -- like calling a parse function with a literal that you know is valid. Sparingly, though. If there's any chance you're wrong, use a proper catch.
Error Sets -- Composable Error Types
One of Zig's most elegant design decisions: error sets can be combined. When a function calls multiple things that might fail in different ways, you don't need to create a new umbrella error type manually -- you merge them:
const NetworkError = error{ ConnectionRefused, Timeout, DnsLookupFailed };
const ParseError = error{ InvalidFormat, MissingField, EncodingError };
// Merge two error sets with ||
fn fetchAndParse(url: []const u8) (NetworkError || ParseError)![]const u8 {
_ = url;
return "parsed data";
}
NetworkError || ParseError creates a combined error set containing all six error values. The function can return any of them. The caller handles them with one catch block, or propagates them with try.
This is substantially cleaner than what you'd do in other languages. In Python, everything is just Exception and you catch-all. In Java, you'd create a FetchAndParseException wrapper and manually chain the cause. In Go, you'd define a new error type and wrap the inner errors with fmt.Errorf("fetch: %w", err). In Zig, the type system handles the merging for you. You don't create a new type. You just combine the existing ones.
There's also a concept called inferred error sets. When a function's return type uses just ! without a specific error set on the left, Zig infers the set from all the error paths in the function body:
fn doSomething() !void {
// Zig looks at all possible error returns in this function
// and builds the error set automatically
try functionThatMightFail();
try anotherRiskyFunction();
}
The !void return type means "this function might error (with an inferred error set) or return void." The compiler figures out the exact errors by analyzing the function body. You'll see this pattern frequently in Zig code -- it's a convenience that saves you from manually listing every possible error, especially when functions call many other fallible functions. Having said that, for public API functions, it's often better to declare an explicit error set so the caller knows exactly what to expect. Inferred error sets are more of an implementation convenience.
defer -- Cleanup That Always Runs
Here's a problem that bites every systems programmer eventually: you acquire a resource (open a file, allocate memory, start a transaction), do some work, and then need to release the resource when you're done. But "when you're done" might be a normal return, or it might be an early return due to an error. If you have five different places where the function might return, you need to put cleanup code at all five. Miss one, and you've got a resource leak.
Python solves this with context managers (with statements). Go solves it with defer. Zig has defer too, and it works beautifully:
const std = @import("std");
pub fn main() !void {
std.debug.print("1. Opening connection\n", .{});
defer std.debug.print("4. Connection closed (defer ran!)\n", .{});
std.debug.print("2. Fetching data\n", .{});
std.debug.print("3. Processing complete\n", .{});
}
Output:
1. Opening connection
2. Fetching data
3. Processing complete
4. Connection closed (defer ran!)
The defer statement registers a piece of code that will run when the current scope exits -- regardless of how it exits. Normal return, error return, break out of a loop -- doesn't matter. The deferred code runs. Always.
This means cleanup code lives right next to acquisition code:
const file = try std.fs.cwd().openFile("trades.csv", .{});
defer file.close(); // guaranteed cleanup, even on error
// ... use the file for 50 lines of code ...
// file.close() runs automatically when the scope ends
You open the file, and immediately on the next line you declare how to clean it up. You never have to remember to close it at every possible exit point. The defer handles it. If you're familiar with Python's with open("file") as f: pattern, this is the same concept but more general -- it works with any resource, not just objects that implement __enter__ and __exit__.
Multiple defers execute in LIFO order (last in, first out). This matters when cleanup has ordering dependencies:
const std = @import("std");
pub fn main() !void {
std.debug.print("1. Acquire lock\n", .{});
defer std.debug.print("5. Release lock (last acquired, first released)\n", .{});
std.debug.print("2. Open file\n", .{});
defer std.debug.print("4. Close file\n", .{});
std.debug.print("3. Do work\n", .{});
}
Output:
1. Acquire lock
2. Open file
3. Do work
4. Close file
5. Release lock (last acquired, first released)
The file was opened while the lock was held, so the file gets closed before the lock is released. LIFO ensures resources are released in the reverse order they were acquired. This is the right default for almost all resource management scenarios -- you undo things in reverse order, just like unstacking plates.
errdefer -- Cleanup Only on Error
This is the one that's unique to Zig, and it's brilliant. Sometimes you need cleanup that should run only if the function fails, not when it succeeds.
Think about it: you're building something step by step. Allocate memory, initialize it, validate it, return it. If initialization fails after the allocation, you need to free the memory. But if everything succeeds, you don't want to free it -- you're returning it to the caller! Regular defer would free it unconditionally, which is wrong. You need conditional cleanup that runs on error only.
const std = @import("std");
const OrderError = error{ InvalidSide, ValidationFailed };
const Order = struct {
side: []const u8,
quantity: f64,
price: f64,
validated: bool,
fn init(self: *Order) !void {
if (self.quantity <= 0) return OrderError.ValidationFailed;
self.validated = true;
}
};
fn createOrder(allocator: std.mem.Allocator, side: []const u8, qty: f64, price: f64) !*Order {
const order = try allocator.create(Order);
errdefer allocator.destroy(order); // only runs if we return an error below
order.* = .{
.side = side,
.quantity = qty,
.price = price,
.validated = false,
};
try order.init(); // if this fails, errdefer cleans up the allocation
return order; // success -- errdefer does NOT run
}
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// This should succeed
const good_order = try createOrder(allocator, "buy", 0.5, 68000.0);
defer allocator.destroy(good_order);
std.debug.print("Order created: {s} {d:.1} @ ${d:.0}\n", .{
good_order.side, good_order.quantity, good_order.price,
});
// This should fail (quantity <= 0) and errdefer will clean up
_ = createOrder(allocator, "sell", -1.0, 68000.0) catch |err| {
std.debug.print("Order rejected: {}\n", .{err});
};
}
Output:
Order created: buy 0.5 @ $68000
Order rejected: error.ValidationFailed
Look at the createOrder function. It allocates memory, then sets up the order, then validates it. The errdefer allocator.destroy(order) line says: "if anything below this point returns an error, free this memory." If order.init() fails, the errdefer runs and the allocation is cleaned up. If everything succeeds and we return the order pointer, the errdefer does not run -- the caller now owns that memory.
This is a pattern you'll see constantly in Zig code that deals with resources. Acquire, errdefer-release, setup, return. It's impossible to leak the resource on error, and impossible to accidentally free it on success. The compiler and the errdefer mechansim do the bookkeeping for you.
(Don't worry if the allocator stuff looks unfamiliar -- we'll go deep on memory management and allocators in a later episode. For now, just understand the pattern: errdefer is your safety net for error-path cleanup.)
Optionals -- Values That Might Not Exist
Errors represent failures -- something went wrong. But sometimes a function legitimately has no result, and that's not a failure. Searching a list for an element that isn't there isn't an error -- it's a valid outcome. A configuration value that might not be set isn't an error -- it's just absent.
For these cases, Zig has optionals:
const std = @import("std");
fn findTicker(tickers: []const []const u8, target: []const u8) ?usize {
for (tickers, 0..) |t, i| {
if (std.mem.eql(u8, t, target)) return i;
}
return null;
}
pub fn main() void {
const list = [_][]const u8{ "BTC", "ETH", "SOL", "AVAX" };
// Unwrap with if
if (findTicker(&list, "SOL")) |idx| {
std.debug.print("Found SOL at index {d}\n", .{idx});
} else {
std.debug.print("SOL not found\n", .{});
}
// Default with orelse
const idx = findTicker(&list, "DOGE") orelse 999;
std.debug.print("DOGE index (or 999): {d}\n", .{idx});
// orelse unreachable -- "I guarantee this is not null"
const btc_idx = findTicker(&list, "BTC") orelse unreachable;
std.debug.print("BTC is at index {d}\n", .{btc_idx});
}
Output:
Found SOL at index 2
DOGE index (or 999): 999
BTC is at index 0
The ?usize return type means "either a usize or null." That's it. No exceptions, no sentinel values like -1, no magic numbers. Either you have the value, or you have null. And just like error unions, the compiler forces you to handle both cases.
Notice the parallel with error unions:
| Errors | Optionals | |||||
|---|---|---|---|---|---|---|
| Type syntax | ErrorType!SuccessType | ?SuccessType | ||||
| "Has value" case | success value | the value | ||||
| "No value" case | error value (with info about WHY) | null (no info needed) | ||||
| Propagate up | try | (use orelse return null) | ||||
| Default value | catch default | orelse default | ||||
| Unwrap safely | `catch \ | err\ | { ... }` | `if (optional) \ | val\ | { ... }` |
| Assert present | catch unreachable | orelse unreachable |
The pattern is consistent. Once you learn error unions, optionals feel natural -- it's the same concept with a different semantic: "something went wrong" vs "nothing is there."
If you've used Rust, this is Option<T> and Result<T, E>. If you've used Haskell, it's Maybe a and Either e a. If you've used Swift, it's Optional and Result. The concept isn't unique to Zig, but the integration with Zig's explicit, no-hidden-cost philosophy makes it feel particularly clean.
In Python, the equivalent of optionals is... returning None. Which is fine until someone forgets to check for None and gets a NoneType has no attribute 'x' error at runtime. In Zig, the type system catches it at compile time. If a function returns ?usize, you cannot use the result as a usize without unwrapping it first. The compiler doesn't let you.
When to Use Errors vs Optionals
This trips people up at first, so let me give you a clear decision framework:
| Situation | Use | Why |
|---|---|---|
| Trade execution might fail | Error (Error!T) | Caller needs to know why it failed |
| Ticker not in list | Optional (?T) | Absence is normal, not a failure |
| File doesn't open | Error | Something went wrong, caller needs the reason |
| Lookup returns no result | Optional | Empty result is valid, not exceptional |
| Insufficient balance | Error | Invalid operation that shouldn't happen silently |
| HashMap key not found | Optional | Normal case in key-value lookups |
| Network request times out | Error | Failure with a specific cause |
| First element of empty slice | Optional | Emptiness is a normal state |
The rule of thumb: if the caller needs to know WHY there's no value, use an error. If "nothing here" is sufficient information, use an optional.
Think of it this way: errors carry information about what went wrong (InsufficientBalance, ConnectionRefused, InvalidInput). Optionals carry no information -- just "present" or "absent." Choose based on how much context the caller needs.
Complete Example: Order Validator
Let me put all of this together in a real program that uses errors, optionals, try, catch, defer, and errdefer. This is the kind of code you'd actually write in a production Zig program:
const std = @import("std");
const OrderError = error{
InvalidSide,
InvalidQuantity,
InsufficientBalance,
PairNotSupported,
};
const Order = struct {
pair: []const u8,
side: []const u8,
quantity: f64,
price: f64,
};
// Returns optional -- "not found" is normal, not an error
fn findPair(supported: []const []const u8, pair: []const u8) ?usize {
for (supported, 0..) |p, i| {
if (std.mem.eql(u8, p, pair)) return i;
}
return null;
}
// Returns error union -- invalid orders are failures
fn validateOrder(order: Order, balance: f64, supported_pairs: []const []const u8) OrderError!f64 {
// Optional used here -- pair lookup returning null is converted to an error
_ = findPair(supported_pairs, order.pair) orelse return OrderError.PairNotSupported;
if (!std.mem.eql(u8, order.side, "buy") and !std.mem.eql(u8, order.side, "sell"))
return OrderError.InvalidSide;
if (order.quantity <= 0) return OrderError.InvalidQuantity;
const cost = order.price * order.quantity;
if (std.mem.eql(u8, order.side, "buy") and cost > balance)
return OrderError.InsufficientBalance;
return if (std.mem.eql(u8, order.side, "buy")) balance - cost else balance + cost;
}
fn processOrders(orders: []const Order, initial_balance: f64, pairs: []const []const u8) void {
var balance = initial_balance;
var accepted: u32 = 0;
var rejected: u32 = 0;
std.debug.print("=== Order Processing ===\n", .{});
std.debug.print("Starting balance: ${d:.2}\n\n", .{balance});
for (orders, 0..) |order, i| {
std.debug.print("Order #{d}: {s} {d:.4} {s} @ ${d:.2}\n", .{
i + 1, order.side, order.quantity, order.pair, order.price,
});
const new_balance = validateOrder(order, balance, pairs) catch |err| {
std.debug.print(" REJECTED: {}\n\n", .{err});
rejected += 1;
continue;
};
const pnl = new_balance - balance;
balance = new_balance;
accepted += 1;
std.debug.print(" ACCEPTED: balance ${d:.2} ({d:+.2})\n\n", .{ balance, pnl });
}
std.debug.print("--- Summary ---\n", .{});
std.debug.print(" Accepted: {d}, Rejected: {d}\n", .{ accepted, rejected });
std.debug.print(" Final balance: ${d:.2}\n", .{balance});
}
pub fn main() void {
const pairs = [_][]const u8{ "BTC/USD", "ETH/USD", "SOL/USD" };
const orders = [_]Order{
.{ .pair = "BTC/USD", .side = "buy", .quantity = 0.1, .price = 68000.0 },
.{ .pair = "ETH/USD", .side = "sell", .quantity = 2.0, .price = 3200.0 },
.{ .pair = "DOGE/USD", .side = "buy", .quantity = 1000, .price = 0.15 },
.{ .pair = "BTC/USD", .side = "buy", .quantity = -0.5, .price = 68000.0 },
.{ .pair = "SOL/USD", .side = "buy", .quantity = 50.0, .price = 142.0 },
};
processOrders(&orders, 10000.0, &pairs);
}
Output:
=== Order Processing ===
Starting balance: $10000.00
Order #1: buy 0.1000 BTC/USD @ $68000.00
ACCEPTED: balance $3200.00 (-6800.00)
Order #2: sell 2.0000 ETH/USD @ $3200.00
ACCEPTED: balance $9600.00 (+6400.00)
Order #3: buy 1000.0000 DOGE/USD @ $0.15
REJECTED: error.PairNotSupported
Order #4: buy -0.5000 BTC/USD @ $68000.00
REJECTED: error.InvalidQuantity
Order #5: buy 50.0000 SOL/USD @ $142.00
REJECTED: error.InsufficientBalance
--- Summary ---
Accepted: 2, Rejected: 3
Final balance: $9600.00
Look at how errors, optionals, try, and catch work together here:
findPairreturns an optional (?usize) because not finding a pair is a normal lookup result, not a failurevalidateOrderconverts the optional to an error:_ = findPair(...) orelse return OrderError.PairNotSupported;-- if the lookup returns null, the validation failsprocessOrderscatches errors fromvalidateOrderand continues processing the next order instead of stopping the whole batch- Each rejected order prints the specific reason (the error value), so you know exactly what went wrong
This is what well-designed error handling looks like. Each layer chooses the right tool (optional vs error), and errors flow cleanly through the call chain with full information preserved.
A Note on !void and Main's Return Type
You might have noticed that pub fn main() void doesn't return an error union, but earlier I used pub fn main() !void. Both are valid. When main returns !void, any uncaught error causes the Zig runtime to print the error and exit with a non-zero status code. When main returns void, you must handle all errors yourself.
For programs where errors should terminate the whole thing, !void is convenient:
pub fn main() !void {
const file = try std.fs.cwd().openFile("config.txt", .{});
defer file.close();
// if openFile fails, the error prints and the program exits
}
For programs that need to handle errors gracefully and keep running (like our order processor above), void with explicit catch is more appropriate.
What We Didn't Cover (Yet)
Error handling connects to many things we haven't covered. A few to be aware of:
Memory management. The errdefer pattern I showed becomes critical once you're allocating memory manually. Zig doesn't have a garbage collector -- when you allocate, you must free. defer and errdefer are the primary tools that make this manageable. We'll get deep into allocators and memory in a later episode.
Slices and strings. We've been passing []const u8 around for strings. That type -- a slice -- has its own set of interesting properties and safety characteristics. Coming soon.
Structs and enums. The error sets we used today are closely related to Zig's enum type. And structs (which we saw briefly as anonymous return types in ep003) can have methods, which means they can return error unions too. When we combine structs, enums, error handling, and memory management, we'll be able to build genuinely sophisticated programs.
For now, though, you have a complete error handling toolkit. Error unions, try, catch, defer, errdefer, optionals, orelse. These are the tools you'll use in every non-trivial Zig program, and understanding them well is what separates "I can write hello world in Zig" from "I can write real software in Zig."
Exercises
Actually do these. Every one of them. I know I keep saying this, but it genuinly matters. Reading code teaches you the concepts; writing code and hitting the compiler errors teaches you the language. Zig's error messages are excellent -- when you make a mistake, read the error carefully. It almost always tells you exactly what's wrong and how to fix it.
Write a function
fn safeDivide(a: f64, b: f64) error{DivisionByZero}!f64that returns an error whenb == 0. Call it frommainwith several values (including 0) and handle the error withcatch.Chain two fallible functions with
try: writefn fetchPrice(pair: []const u8) error{NotFound}!f64andfn calculateValue(pair: []const u8, qty: f64) error{NotFound}!f64where the second calls the first withtry. Handle the error inmain.Write
fn findAsset(assets: []const []const u8, target: []const u8) ?usizethat returns the index or null. Call it from main, useorelsefor a default, and useif (result) |idx|for conditional unwrapping. Try both a found and not-found case.Create a scope with three
deferstatements that print "cleanup A", "cleanup B", "cleanup C" and verify that they execute in LIFO order (C, B, A).Write a function that allocates a
u32value using astd.heap.GeneralPurposeAllocator, sets it to a specific number, and returns it. Useerrdeferto free the allocation if anything fails between allocation and the final return. (Hint: useallocator.create(u32)andallocator.destroy(ptr).)Build a small order validation system: define an
OrderErrorerror set with at least three variants, a function that validates orders and returns either the error or the resulting balance, and a main that processes an array of orders, printing accepted/rejected for each one.
These exercises build on each other in complexity. Exercises 1-4 test individual concepts. Exercise 5 connects error handling to memory (a preview of what's coming). Exercise 6 ties everything together into a complete small program, similar to our Trade Journal from ep003 but with error handling woven through it.
The patterns you've learned today -- error unions, try, catch, defer, errdefer, optionals -- are not optional knowledge. They're fundamental to writing Zig. Every library, every standard library function, every non-trivial Zig program uses these patterns constantly. Get comfortable with them now, because everything we build from here forward assumes you can read and write error-handling code fluently.