Learn Zig Series (#6) - Structs, Enums, and Tagged Unions
What will I learn
- You will learn how to define structs with fields, default values, and methods;
- the difference between value methods and pointer (mutating) methods;
- how nested structs compose naturally without inheritance;
- enums as proper types with methods and exhaustive switching;
- enums with explicit integer backing values;
- tagged unions -- Zig's powerful sum types;
- anonymous structs for quick multi-value returns.
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)
- Learn Zig Series (#5) - Arrays, Slices, and Strings
- Learn Zig Series (#6) - Structs, Enums, and Tagged Unions (this post)
Learn Zig Series (#6) - Structs, Enums, and Tagged Unions
Welcome back! In episode #5 we went deep on arrays, slices, and strings -- fixed-size arrays on the stack, slices as pointer-plus-length views into memory, the fact that strings in Zig are literally just []const u8 byte slices, and we built a configuration parser that combined std.mem string operations with the error handling patterns from ep004. We also used anonymous structs to return multiple values from parseKeyValue. If you haven't gone through that episode yet, do it first -- today builds directly on those foundations.
At the end of ep005 I mentioned that we'd been using anonymous structs (like that parseKeyValue return type) without formally learning about structs, enums, and tagged unions. Well, this is that episode ;-)
Up to this point we've been working with Zig's built-in types: integers, floats, booleans, arrays, slices. These are the raw materials. But real programs model real domains -- you don't just have "an f64 and a []const u8 and another f64 floating around." You have an order with a pair, a side, a price, and a quantity. You have a portfolio with positions, each position having its own fields. You need to group related data together, give it a name, and attach behavior to it. That's what structs do.
And once you have structs, you also need types that represent "one of several possibilities" -- an order that's either a market order or a limit order, not both. A network message that could be a heartbeat, an error, or a data payload. That's where enums and tagged unions come in.
These three things -- structs, enums, and tagged unions -- are how you model your problem domain in Zig. No classes. No inheritance hierarchies. No abstract factory pattern. Just data types that say exactly what they contain and nothing more. Let's dive right in.
Solutions to Episode 5 Exercises
Before we start on new material, here are the solutions to last episode's exercises. As always, if you actually typed these out and compiled them (and I really hope you did!), compare your solutions:
Exercise 1 -- averages of half-slices:
const std = @import("std");
fn average(values: []const f64) f64 {
if (values.len == 0) return 0;
var sum: f64 = 0;
for (values) |v| sum += v;
return sum / @as(f64, @floatFromInt(values.len));
}
pub fn main() void {
const prices = [_]f64{ 64000, 65200, 63800, 67100, 68400, 66900, 67500, 68200, 69100, 67800 };
const first_half = prices[0..5];
const second_half = prices[5..];
const avg_first = average(first_half);
const avg_second = average(second_half);
std.debug.print("First half avg: ${d:.2}\n", .{avg_first});
std.debug.print("Second half avg: ${d:.2}\n", .{avg_second});
std.debug.print("Higher half: {s}\n", .{
if (avg_first > avg_second) "first" else "second",
});
}
Slicing into halves with prices[0..5] and prices[5..], then passing both to the same average function. The function takes []const f64 -- it doesn't care whether it's looking at 5 elements or 500. That's the power of slices we talked about in ep005.
Exercise 2 -- contains:
fn contains(haystack: []const u8, needle: u8) bool {
for (haystack) |c| {
if (c == needle) return true;
}
return false;
}
Simple iteration with early return on match. Note that this checks for a single byte (u8), not a substring -- for substring searching you'd use std.mem.indexOf.
Exercise 3 -- split by ";", then by "=":
const std = @import("std");
pub fn main() void {
const data = "BTC=68000;ETH=3200;SOL=142";
var pairs = std.mem.splitSequence(u8, data, ";");
while (pairs.next()) |pair| {
var kv = std.mem.splitSequence(u8, pair, "=");
const ticker = kv.next() orelse continue;
const price = kv.next() orelse continue;
std.debug.print("{s}: ${s}\n", .{ ticker, price });
}
}
Two nested splitSequence calls. The orelse continue handles the case where a piece doesn't have both parts -- same optional pattern from ep004.
Exercise 4 -- longest string: iterate with for, track max_len and longest slice. Return ?[]const u8 since an empty input has no longest element.
Exercise 5 -- const banner = "=== " ++ "Scipio" ++ " ==="; -- compile-time string concatenation, no allocator needed.
Exercise 6 -- config parser: essentially the same parseKeyValue + findValue pattern from the ep005 walkthrough. If yours looks different but works correctly, that's fine.
Now -- structs, enums, and tagged unions!
Structs -- Your Own Data Types
A struct is a named group of fields. It's the fundamental building block for modeling your domain:
const std = @import("std");
const Trade = struct {
pair: []const u8,
side: []const u8,
price: f64,
quantity: f64,
// Default values
fee_pct: f64 = 0.1,
executed: bool = false,
};
pub fn main() void {
const trade = Trade{
.pair = "BTC/USD",
.side = "buy",
.price = 68000.0,
.quantity = 0.5,
// fee_pct and executed use defaults
};
std.debug.print("{s} {s} {d:.4} @ ${d:.2} (fee: {d:.1}%)\n", .{
trade.side, trade.pair, trade.quantity, trade.price, trade.fee_pct,
});
}
Output:
buy BTC/USD 0.5000 @ $68000.00 (fee: 0.1%)
Let me walk through what's happening here:
Dot-prefix initialization: .pair = "BTC/USD" not pair = "BTC/USD". The dot prefix is Zig's syntax for struct field initialization. If you've been writing Python classes with self.pair = pair in __init__, Zig is more concise -- you declare the fields once in the struct definition, and the initialization syntax mirrors it.
Default values: Fields with = value after the type get that value when omitted during initialization. fee_pct defaults to 0.1, executed defaults to false. So we only need to provide pair, side, price, and quantity. This is cleaner than Python's def __init__(self, pair, side, price, quantity, fee_pct=0.1, executed=False) -- you declare the default at the field level, not in a constructor function.
All fields without defaults MUST be initialized. If you remove .price = 68000.0 from the initialization, the compiler rejects your program. No zero-value structs, no uninitialized fields, no "oops I forgot to set that." Compare this with Go, where every struct field silently gets a zero value if you don't set it. That's convenient until your order has a price of 0.0 because you forgot to set it, and it gets submitted to an exchange. Zig doesn't let that happen.
Accessing fields: trade.pair, trade.price -- dot syntax, same as everywhere else. Since trade is const, you can't modify any fields after initialization.
If you've been following the Learn Python Series, you're used to @dataclass or regular classes with __init__. Zig structs serve a similar purpose -- they group related data -- but there's no constructor, no self, no class inheritance, no method resolution order. Just fields. Let's add behavior next.
Methods
Functions defined inside a struct that take self as their first parameter get dot-syntax calling. This is Zig's approach to methods -- no special keyword, no class declaration, just a function that happens to live inside a struct and take that struct as an argument:
const std = @import("std");
const Position = struct {
entry_price: f64,
quantity: f64,
side: f64, // 1.0 = long, -1.0 = short
fn pnl(self: Position, current_price: f64) f64 {
return (current_price - self.entry_price) * self.quantity * self.side;
}
fn pnlPercent(self: Position, current_price: f64) f64 {
return (current_price - self.entry_price) / self.entry_price * 100.0 * self.side;
}
fn value(self: Position, current_price: f64) f64 {
return current_price * self.quantity;
}
};
pub fn main() void {
const pos = Position{ .entry_price = 64000, .quantity = 0.5, .side = 1.0 };
const current = 68500.0;
std.debug.print("PnL: ${d:.2} ({d:.1}%)\n", .{ pos.pnl(current), pos.pnlPercent(current) });
std.debug.print("Position value: ${d:.2}\n", .{pos.value(current)});
}
Output:
PnL: $2250.00 (7.0%)
Position value: $34250.00
pos.pnl(current) is syntactic sugar for Position.pnl(pos, current). Both work. The dot syntax is just more readable. When you call pos.pnl(current), Zig automatically passes pos as the first argument (self). There's no magic this pointer, no vtable lookup, no hidden allocation -- it's literally just a function call with the struct as the first argument.
This is one of those things that Python developers sometimes find refreshing: in Python, self is also just the first parameter (you write it explicitly in the method definition), but there's a whole class system, __init__, __repr__, super(), MRO, metaclasses, descriptors... all sitting underneath. In Zig, what you see is what you get. A method is a function. A struct is a collection of fields. No layers.
Mutable Methods -- Pointer Parameters
The methods we just wrote take self: Position by value -- they receive a copy of the struct. That's fine for reading, but what if you need to modify the struct? You take a pointer:
const std = @import("std");
const Account = struct {
balance: f64 = 0,
trade_count: u32 = 0,
fn deposit(self: *Account, amount: f64) void {
self.balance += amount;
}
fn withdraw(self: *Account, amount: f64) !void {
if (amount > self.balance) return error.InsufficientFunds;
self.balance -= amount;
}
fn executeTrade(self: *Account, cost: f64) !void {
try self.withdraw(cost);
self.trade_count += 1;
}
fn display(self: Account) void {
std.debug.print("Balance: ${d:.2} ({d} trades)\n", .{ self.balance, self.trade_count });
}
};
pub fn main() void {
var acc = Account{};
acc.deposit(10000.0);
acc.executeTrade(3400.0) catch |err| {
std.debug.print("Trade failed: {}\n", .{err});
};
acc.executeTrade(1200.0) catch |err| {
std.debug.print("Trade failed: {}\n", .{err});
};
acc.display(); // Balance: $5400.00 (2 trades)
// Try to overdraw
acc.executeTrade(99999.0) catch |err| {
std.debug.print("Trade failed: {}\n", .{err});
};
acc.display(); // Balance: $5400.00 (2 trades) -- unchanged
}
Output:
Balance: $5400.00 (2 trades)
Trade failed: error.InsufficientFunds
Balance: $5400.00 (2 trades)
The key distinction: self: *Account = mutable pointer (can modify the struct). self: Account = value copy (read-only). Look at display vs deposit -- display takes a value because it only reads fields, deposit takes a pointer because it modifies balance.
Notice that executeTrade calls self.withdraw(cost) with try -- error propagation from ep004 works perfectly inside struct methods. The withdraw method returns an error union (!void), and executeTrade propagates it. The caller in main catches the error. Errors flow through methods exactly the same way they flow through standalone functions. No special rules, no exception objects, just the same try/catch pattern.
Also notice: var acc = Account{}. We use var because we're going to mutate acc through the pointer methods. If we'd written const acc = Account{}, the compiler would reject the deposit call because you can't get a mutable pointer to a const binding. This is the const vs var decision from ep002 applied to structs -- and the compiler enforces it at every level.
Nested Structs -- Composition Over Inheritance
Zig has no inheritance. No class Portfolio extends Account. No super(). No method resolution order. No diamond problem. Instead, you compose -- structs contain other structs:
const std = @import("std");
const Asset = struct {
symbol: []const u8,
amount: f64,
fn value(self: Asset, price: f64) f64 {
return self.amount * price;
}
};
const Portfolio = struct {
name: []const u8,
assets: [4]?Asset,
count: u32 = 0,
fn addAsset(self: *Portfolio, symbol: []const u8, amount: f64) !void {
if (self.count >= 4) return error.PortfolioFull;
self.assets[self.count] = Asset{ .symbol = symbol, .amount = amount };
self.count += 1;
}
fn totalValue(self: Portfolio, prices: [4]f64) f64 {
var total: f64 = 0;
for (self.assets[0..self.count], 0..) |maybe_asset, i| {
if (maybe_asset) |asset| {
total += asset.value(prices[i]);
}
}
return total;
}
fn display(self: Portfolio, prices: [4]f64) void {
std.debug.print("=== {s} ===\n", .{self.name});
for (self.assets[0..self.count], 0..) |maybe_asset, i| {
if (maybe_asset) |asset| {
std.debug.print(" {s}: {d:.4} (${d:.2})\n", .{
asset.symbol, asset.amount, asset.value(prices[i]),
});
}
}
std.debug.print(" Total: ${d:.2}\n", .{self.totalValue(prices)});
}
};
pub fn main() void {
var portfolio = Portfolio{
.name = "Main Portfolio",
.assets = .{ null, null, null, null },
};
portfolio.addAsset("BTC", 0.5) catch {};
portfolio.addAsset("ETH", 5.0) catch {};
portfolio.addAsset("SOL", 50.0) catch {};
const prices = [4]f64{ 68000, 3200, 142, 0 };
portfolio.display(prices);
}
Output:
=== Main Portfolio ===
BTC: 0.5000 ($34000.00)
ETH: 5.0000 ($16000.00)
SOL: 50.0000 ($7100.00)
Total: $57100.00
A few things to unpack here:
[4]?Asset -- an array of four optional Assets. The ? is the optional type from ep004. Each slot is either an Asset or null. This is a simple fixed-capacity collection. We use null for empty slots and the count field tracks how many are filled.
if (maybe_asset) |asset| -- optional unwrapping, same pattern from ep004 and ep005. If the slot has an asset, we get it as asset. If it's null, the block is skipped.
Composition: Portfolio contains Asset values. There's no "Portfolio is-a Asset" relationship. There's no base class, no virtual methods, no polymorphism. Just data inside data. Methods on Asset are called through the nested value: asset.value(prices[i]). If you've ever fought with deep inheritance hierarchies in Java or Python -- where changing one base class method breaks six subclasses you didn't know existed -- you'll appreciate how straightforward this is.
Is this more verbose than Python's class Portfolio: def __init__(self): self.assets = []? Yes. But it's also completely explicit about memory layout, capacity, and ownership. You know exactly how much memory this struct uses. You know it can hold at most 4 assets. You know that the assets live inside the portfolio struct, not somewhere on the heap behind a pointer. When we get to memory management and allocators, this explicitness becomes critical.
Enums -- One of Several Named Values
An enum in Zig is a proper type with a fixed set of named values. Think Python's enum.Enum, but with methods and exhaustive switching:
const std = @import("std");
const OrderSide = enum {
buy,
sell,
fn multiplier(self: OrderSide) f64 {
return switch (self) {
.buy => -1.0, // costs money
.sell => 1.0, // earns money
};
}
fn label(self: OrderSide) []const u8 {
return switch (self) {
.buy => "BUY",
.sell => "SELL",
};
}
};
pub fn main() void {
const side = OrderSide.buy;
std.debug.print("Side: {s}, multiplier: {d:.0}\n", .{ side.label(), side.multiplier() });
}
Output:
Side: BUY, multiplier: -1
Enums can have methods, just like structs. And the switch inside those methods must be exhaustive -- every variant must be handled. If you add a third variant .cancel to OrderSide and forget to update the multiplier method, the compiler rejects your program. Not at runtime. At compile time. This is the same exhaustive switching from ep003, now applied to your own types.
If you've used enums in Python, you know that Python's enums don't enforce exhaustive matching -- you can if side == OrderSide.BUY and forget the else and Python won't complain. In Zig, the compiler keeps you honest.
Enums with Integer Backing Values
Sometimes you need your enum values to map to specific numbers -- wire protocols, file formats, database codes:
const std = @import("std");
const OrderType = enum(u8) {
market = 0,
limit = 1,
stop = 2,
stop_limit = 3,
fn requiresPrice(self: OrderType) bool {
return self != .market;
}
fn description(self: OrderType) []const u8 {
return switch (self) {
.market => "execute at current price",
.limit => "execute at specified price or better",
.stop => "trigger at price, then market",
.stop_limit => "trigger at stop, then limit order",
};
}
};
pub fn main() void {
const otype = OrderType.limit;
std.debug.print("Type: limit\n", .{});
std.debug.print("Code: {d}\n", .{@intFromEnum(otype)}); // 1
std.debug.print("Requires price: {}\n", .{otype.requiresPrice()});
std.debug.print("Description: {s}\n", .{otype.description()});
// Convert back from integer
const from_code = @as(OrderType, @enumFromInt(2));
std.debug.print("\nCode 2 = {s}\n", .{from_code.description()});
}
Output:
Type: limit
Code: 1
Requires price: true
Description: execute at specified price or better
Code 2 = trigger at price, then market
enum(u8) means the enum values are backed by u8 integers. @intFromEnum converts enum to integer, @enumFromInt converts back. This is useful when you need to serialize/deserialize enum values -- writing to a file, sending over a network, storing in a database.
The backing type can be any integer: u8, u16, u32, i32, etc. Choose the smallest type that fits your range. For a wire protocol with 4 order types, u8 is plenty.
Tagged Unions -- "One of Several Things"
Here's where Zig gets genuinly interesting. A tagged union is a type that can hold one of several different types of data, and it tracks which one is currently active. Think of it as "this value is either X or Y or Z, and the type system knows which one it is right now."
const std = @import("std");
const OrderPrice = union(enum) {
market: void,
limit: f64,
stop: f64,
stop_limit: struct { stop: f64, limit: f64 },
fn display(self: OrderPrice) void {
switch (self) {
.market => std.debug.print("MARKET (best available)", .{}),
.limit => |p| std.debug.print("LIMIT ${d:.2}", .{p}),
.stop => |p| std.debug.print("STOP ${d:.2}", .{p}),
.stop_limit => |sl| std.debug.print("STOP ${d:.2} LIMIT ${d:.2}", .{ sl.stop, sl.limit }),
}
}
fn isPassive(self: OrderPrice) bool {
return switch (self) {
.market => false,
.limit => true,
.stop => false,
.stop_limit => true,
};
}
};
pub fn main() void {
const orders = [_]OrderPrice{
OrderPrice{ .market = {} },
OrderPrice{ .limit = 65000.0 },
OrderPrice{ .stop = 60000.0 },
OrderPrice{ .stop_limit = .{ .stop = 60000, .limit = 59500 } },
};
for (orders, 0..) |order, i| {
std.debug.print("Order #{d}: ", .{i + 1});
order.display();
std.debug.print(" (passive: {})\n", .{order.isPassive()});
}
}
Output:
Order #1: MARKET (best available) (passive: false)
Order #2: LIMIT $65000.00 (passive: true)
Order #3: STOP $60000.00 (passive: false)
Order #4: STOP $60000.00 LIMIT $59500.00 (passive: true)
Let me break this down because tagged unions are one of the most powerful features in Zig and they deserve a proper explanation.
union(enum) means "this is a union that automatically tracks which variant is active." The (enum) part creates an implicit enum tag that the runtime uses to know what's inside. Without it, you'd have a bare union (like C), where accessing the wrong variant is undefined behavior. With (enum), the compiler forces exhaustive switching -- you must handle every variant.
Each variant has its own payload type. .market holds void (nothing -- it's just a marker). .limit holds a single f64 (the limit price). .stop_limit holds an anonymous struct with two fields. The variants don't need to hold the same type -- that's the whole point. An OrderPrice is either a market order (no price), or a limit order (one price), or a stop-limit order (two prices).
The switch captures the payload. When you write .limit => |p|, the p variable holds the f64 limit price. When you write .stop_limit => |sl|, the sl variable holds the struct with .stop and .limit fields. The compiler knows which variant is active, so it gives you the right payload. And it forces you to handle every variant -- no forgotten cases.
Python has nothing equivalent to this. The closest you get is isinstance checks at runtime:
# Python -- runtime type checking, no compiler enforcement
if isinstance(order, MarketOrder):
process_market(order)
elif isinstance(order, LimitOrder):
process_limit(order)
# forgot StopLimitOrder? Python won't tell you.
In Zig, the compiler tells you immediately. Add a new variant to the union? Every switch statement that touches it must be updated, or the program won't compile. This is the same exhaustive-switching guarantee that enums provide, but now with heterogeneous data attached.
Where would you use tagged unions in practice? Anywhere data can be "one of several shapes":
- An order price that might be market, limit, or stop-limit
- A WebSocket message that could be text, binary, or a control frame
- A config value that's either a string, a number, or a boolean
- A parse result that's either a valid token or an error with context
- A command that could be insert, update, delete, or query -- each with different parameters
If you've used Rust, this is enum (Rust's enums are tagged unions, confusingly). If you've used Haskell, this is a sum type with constructors. If you've used TypeScript, this is a discriminated union. The concept exists in many languages -- Zig's version is clean, zero-overhead, and integrated with the compiler's exhaustiveness checking.
Combining Structs, Enums, and Tagged Unions
The real power shows up when you combine all three. Let's build something more realistic -- an order processing system where different order types carry different data:
const std = @import("std");
const Side = enum {
buy,
sell,
fn label(self: Side) []const u8 {
return switch (self) {
.buy => "BUY",
.sell => "SELL",
};
}
};
const PriceSpec = union(enum) {
market: void,
limit: f64,
fn describe(self: PriceSpec) void {
switch (self) {
.market => std.debug.print("@ MARKET", .{}),
.limit => |p| std.debug.print("@ ${d:.2}", .{p}),
}
}
};
const Order = struct {
pair: []const u8,
side: Side,
quantity: f64,
price: PriceSpec,
fn cost(self: Order, market_price: f64) f64 {
const exec_price = switch (self.price) {
.market => market_price,
.limit => |p| p,
};
return exec_price * self.quantity;
}
fn display(self: Order) void {
std.debug.print("{s} {s} {d:.4} ", .{
self.side.label(), self.pair, self.quantity,
});
self.price.describe();
}
};
pub fn main() void {
const orders = [_]Order{
.{ .pair = "BTC/USD", .side = .buy, .quantity = 0.25, .price = .{ .market = {} } },
.{ .pair = "ETH/USD", .side = .sell, .quantity = 3.0, .price = .{ .limit = 3500.0 } },
.{ .pair = "SOL/USD", .side = .buy, .quantity = 100.0, .price = .{ .limit = 135.0 } },
};
const market_prices = [_]f64{ 68000, 3200, 142 };
const pairs = [_][]const u8{ "BTC/USD", "ETH/USD", "SOL/USD" };
std.debug.print("=== Order Book ===\n", .{});
for (orders, 0..) |order, i| {
std.debug.print("#{d}: ", .{i + 1});
order.display();
// Find market price for this pair
for (pairs, 0..) |pair, j| {
if (std.mem.eql(u8, pair, order.pair)) {
std.debug.print(" (cost: ${d:.2})", .{order.cost(market_prices[j])});
break;
}
}
std.debug.print("\n", .{});
}
}
Output:
=== Order Book ===
#1: BUY BTC/USD 0.2500 @ MARKET (cost: $17000.00)
#2: SELL ETH/USD 3.0000 @ $3500.00 (cost: $10500.00)
#3: BUY SOL/USD 100.0000 @ $135.00 (cost: $13500.00)
Look at how the types compose: Order contains a Side enum and a PriceSpec tagged union. The cost method on Order switches on self.price to determine the execution price. Each type is responsible for its own behavior -- Side.label() knows how to display itself, PriceSpec.describe() knows how to format itself. The Order struct coordinates them.
This is composition. Small, focused types that each do one thing, combined into larger structures. No inheritance, no abstract base classes, no "Shape extends Drawable implements Serializable" chains. Just data types and functions on them.
Anonymous Structs -- Quick Returns Without Named Types
Remember in ep005 when parseKeyValue returned struct { key: []const u8, value: []const u8 }? That's an anonymous struct -- you define the type inline, without giving it a name. Useful when a function needs to return multiple values and creating a named type feels like overkill:
const std = @import("std");
fn getMinMax(prices: []const f64) struct { min: f64, max: f64, range: f64 } {
var min = prices[0];
var max = prices[0];
for (prices[1..]) |p| {
if (p < min) min = p;
if (p > max) max = p;
}
return .{ .min = min, .max = max, .range = max - min };
}
fn analyzeVolatility(prices: []const f64) struct { avg: f64, high_pct: f64, low_pct: f64 } {
var sum: f64 = 0;
for (prices) |p| sum += p;
const avg = sum / @as(f64, @floatFromInt(prices.len));
const mm = getMinMax(prices);
return .{
.avg = avg,
.high_pct = (mm.max - avg) / avg * 100.0,
.low_pct = (avg - mm.min) / avg * 100.0,
};
}
pub fn main() void {
const prices = [_]f64{ 64000, 65200, 63800, 67100, 68400, 66900, 67500 };
const mm = getMinMax(&prices);
std.debug.print("Min: ${d:.0}, Max: ${d:.0}, Range: ${d:.0}\n", .{
mm.min, mm.max, mm.range,
});
const vol = analyzeVolatility(&prices);
std.debug.print("Avg: ${d:.0}, High: +{d:.1}%, Low: -{d:.1}%\n", .{
vol.avg, vol.high_pct, vol.low_pct,
});
}
Output:
Min: $63800, Max: $68400, Range: $4600
Avg: $66129, High: +3.4%, Low: -3.5%
Anonymous structs are great for functions that compute multiple related values. You get named fields (.min, .max, .range) instead of positional tuple indexing, and you don't pollute your namespace with a named type that's only used in one place.
Having said that, if you find yourself returning the same anonymous struct shape from multiple functions, it's probably time to give it a name. Anonymous structs are a convenience, not a replacement for proper type design.
Structs and Error Handling Working Together
Let me show you how structs, methods, error unions, and everything we've learned so far combine in a complete program. This builds an order book validator using all the patterns from this episode and previous episodes:
const std = @import("std");
const ValidationError = error{
InvalidQuantity,
InvalidPrice,
InsufficientBalance,
PairNotFound,
};
const Side = enum { buy, sell };
const ValidatedOrder = struct {
pair: []const u8,
side: Side,
quantity: f64,
price: f64,
cost: f64,
fn display(self: ValidatedOrder) void {
const side_str: []const u8 = switch (self.side) {
.buy => "BUY",
.sell => "SELL",
};
std.debug.print(" {s} {d:.4} {s} @ ${d:.2} = ${d:.2}\n", .{
side_str, self.quantity, self.pair, self.price, self.cost,
});
}
};
fn findPrice(pair: []const u8, pairs: []const []const u8, prices: []const f64) ?f64 {
for (pairs, 0..) |p, i| {
if (std.mem.eql(u8, p, pair)) return prices[i];
}
return null;
}
fn validateOrder(
pair: []const u8,
side: Side,
quantity: f64,
price: f64,
balance: f64,
pairs: []const []const u8,
market_prices: []const f64,
) ValidationError!ValidatedOrder {
if (quantity <= 0) return ValidationError.InvalidQuantity;
if (price <= 0) return ValidationError.InvalidPrice;
_ = findPrice(pair, pairs, market_prices) orelse
return ValidationError.PairNotFound;
const cost = price * quantity;
if (side == .buy and cost > balance) return ValidationError.InsufficientBalance;
return ValidatedOrder{
.pair = pair,
.side = side,
.quantity = quantity,
.price = price,
.cost = cost,
};
}
pub fn main() void {
const pairs = [_][]const u8{ "BTC/USD", "ETH/USD", "SOL/USD" };
const market_prices = [_]f64{ 68000, 3200, 142 };
var balance: f64 = 50000.0;
var accepted: u32 = 0;
var rejected: u32 = 0;
std.debug.print("=== Processing Orders (balance: ${d:.2}) ===\n\n", .{balance});
// Batch of test orders
const test_orders = [_]struct { pair: []const u8, side: Side, qty: f64, price: f64 }{
.{ .pair = "BTC/USD", .side = .buy, .qty = 0.5, .price = 68000 },
.{ .pair = "ETH/USD", .side = .sell, .qty = 10.0, .price = 3200 },
.{ .pair = "DOGE/USD", .side = .buy, .qty = 1000, .price = 0.15 },
.{ .pair = "SOL/USD", .side = .buy, .qty = -5, .price = 142 },
.{ .pair = "BTC/USD", .side = .buy, .qty = 1.0, .price = 68000 },
};
for (test_orders, 0..) |t, i| {
std.debug.print("Order #{d}: ", .{i + 1});
const validated = validateOrder(
t.pair, t.side, t.qty, t.price, balance, &pairs, &market_prices,
) catch |err| {
std.debug.print("REJECTED ({any})\n", .{err});
rejected += 1;
continue;
};
validated.display();
if (validated.side == .buy) balance -= validated.cost;
if (validated.side == .sell) balance += validated.cost;
accepted += 1;
}
std.debug.print("\n--- Results ---\n", .{});
std.debug.print("Accepted: {d}, Rejected: {d}\n", .{ accepted, rejected });
std.debug.print("Final balance: ${d:.2}\n", .{balance});
}
Output:
=== Processing Orders (balance: $50000.00) ===
Order #1: BUY 0.5000 BTC/USD @ $68000.00 = $34000.00
Order #2: SELL 10.0000 ETH/USD @ $3200.00 = $32000.00
Order #3: REJECTED (error.PairNotFound)
Order #4: REJECTED (error.InvalidQuantity)
Order #5: REJECTED (error.InsufficientBalance)
--- Results ---
Accepted: 2, Rejected: 3
Final balance: $48000.00
This program uses structs (ValidatedOrder), enums (Side), error sets (ValidationError), optionals (findPrice returns ?f64), error propagation (catch |err| in the main loop), and arrays of anonymous structs for test data. Every pattern we've covered across episodes 2-6, working together.
Compare this to the Order Validator from ep004. Same idea -- process a batch of orders, accept or reject each one. But now we have proper types instead of ad-hoc fields, an enum instead of a string for the side, and a ValidatedOrder struct that carries all the validated data. The types communicate intent. When you read ValidatedOrder, you know this order has passed validation. When you see Side.buy, there's no question whether someone typoed "Buy" or "BUY" or "b". The type system narrows the possibilities.
What We Didn't Cover (Yet)
We've been working with structs that live on the stack and contain fixed-size data. But what happens when a struct needs to hold a dynamicaly sized collection? An array of assets where you don't know the count at compile time? A string that was read from a file at runtime?
That requires memory allocation -- and in Zig, memory allocation is always explicit. Every allocation goes through an allocator that you pass around, and every allocation has a corresponding deallocation. defer and errdefer from ep004 become your primary tools for ensuring nothing leaks. Structs with allocated fields need init and deinit patterns that mirror the errdefer cleanup we previewed in ep004.
And then there are pointers -- the explicit way to reference data that lives elsewhere in memory. Struct fields that point to other structs. Linked structures. The relationship between a slice (which we know well) and the raw pointer underneath it. Understanding pointers is what takes you from "I can write Zig programs with stack data" to "I can build any data structure I need."
Those topics are coming. For now, you have the type-building toolkit: structs for grouping data, enums for named alternatives, and tagged unions for values that can be different things. Combined with the arrays, slices, error handling, and functions from previous episodes, you can model a surprising amount of real-world logic.
Exercises
You know the drill by now. Type these out. Compile them. Read the compiler errors when you get something wrong. Zig's error messages are excellent -- they almost always point you directly at the fix.
Create an
Orderstruct with fieldspair: []const u8,side: Side(using an enum),quantity: f64,price: f64. Add acostmethod that returnsquantity * price. Add adisplaymethod that prints the order details. Create several orders and display them.Create a
TimeFrameenum with variantsm1,m5,m15,h1,h4,d1. Add asecondsmethod that returns the duration in seconds (e.g.m1returns 60,h1returns 3600,d1returns 86400). Add alabelmethod that returns a human-readable string ("1 Minute", "5 Minutes", etc.). Print all timeframes with their durations.Create a tagged union
AlertConditionwith variantsprice_above: f64,price_below: f64,pct_change: f64. Add adescribemethod that prints a human-readable description of the alert. Create an array of different alerts and display them all.Write a function
fn analyzeSlice(values: []const f64) struct { high: f64, low: f64, avg: f64 }that computes all three statistics from a price slice. Return the result as an anonymous struct. Call it from main and print the results.Build a mini portfolio tracker: create
AssetandPortfoliostructs.Portfolioshould have a method to add assets (returning an error if full) and a method to compute total value given an array of prices. Create a portfolio, add several assets, and print the total value. Use optionals for asset lookups and errors for invalid operations.Combine everything: create an order processing system with a
Sideenum, anOrderPricetagged union (market or limit), and anOrderstruct. Write avalidatefunction that checks the order and returns either aValidationErroror a validated order. Process a batch of orders and print accepted/rejected with reasons.
Exercises 1-3 test individual types (structs, enums, tagged unions). Exercise 4 is a quick anonymous struct exercise. Exercise 5 builds on the nested struct pattern with error handling. Exercise 6 ties it all together -- and if you get it working, you've essentially built the complete example from this episode yourself. That's the goal.
The types you learned today are the vocabulary for expressing your program's domain. Combined with the control flow from ep003, the error handling from ep004, and the data containers from ep005, you now have everything you need to model non-trivial problems. What's still missing is the ability to work with data that doesn't fit on the stack -- data whose size you don't know at compile time, data that outlives the scope it was created in, data that needs to grow and shrink dynamically. That's memory management, and it's where Zig's philosophy of "explicit everything" really shines.