Idiomatic Zig Programming
Expert guidance for writing idiomatic Zig code that embodies the Zen of Zig: explicit intent, no hidden control flow, and compile-time over runtime.
Zen of Zig (Core Philosophy)
These principles govern all idiomatic Zig code:
| Principle | Implication |
|---|---|
| Communicate intent precisely | Explicit code; APIs make requirements obvious |
| Edge cases matter | No undefined behaviors glossed over |
| Favor reading over writing | Optimize for clarity and maintainability |
| One obvious way | Avoid multiple complex features for same task |
| Runtime crashes > bugs | Fail fast and loudly, never corrupt state silently |
| Compile errors > runtime crashes | Catch issues at compile-time when possible |
| Resource deallocation must succeed | Design APIs with allocation failure in mind |
| Memory is a resource | Manage memory as consciously as any other resource |
| No hidden control flow | No exceptions, no GC, no implicit allocations |
FP Conceptual Parallels
Zig shares key concepts with functional programming:
| FP Concept | Zig Equivalent |
|---|---|
| Result/Either type | Error union !T (either error or value) |
| Option/Maybe | Optional ?T (nullable type) |
| ADTs / Sum types | Tagged unions with union(enum) |
| Pattern matching | switch with exhaustive handling |
| Explicit effects | Allocator/Io parameters (dependency injection) |
| Immutability preference | const by default, var only when needed |
| Pure functions | Functions without hidden state or allocations |
Workflow Decision Tree
- Declaring a binding? → Use
constunless mutation required - Function needs memory? → Accept
Allocatorparameter, never global alloc - Function can fail? → Return error union
!T, usetryto propagate - Handling an error? → Use
catchwith explicit handler ortryto propagate - Need cleanup on exit? → Use
deferimmediately after acquisition - Cleanup only on error? → Use
errdeferfor conditional cleanup - Need generic code? → Use
comptimetype parameters - Compile-time known value? → Use
comptimeto evaluate at build time - Calling C code? → Use
@cImportfor seamless FFI - Need async I/O? → Pass
Iointerface, useio.async()andfuture.await() - Optimizing hot path? → Consider data-oriented design (SoA vs AoS)
Essential Patterns
Error Unions (Result Type Equivalent)
zig1const FileError = error{ NotFound, PermissionDenied, InvalidPath }; 2 3fn readConfig(path: []const u8) FileError!Config { 4 const file = std.fs.cwd().openFile(path, .{}) catch |err| { 5 return switch (err) { 6 error.FileNotFound => error.NotFound, 7 error.AccessDenied => error.PermissionDenied, 8 else => error.InvalidPath, 9 }; 10 }; 11 defer file.close(); 12 // ... parse config 13 return config; 14} 15 16// Propagate with try (like Rust's ?) 17pub fn main() !void { 18 const config = try readConfig("app.conf"); 19 // ... 20} 21 22// Handle explicitly with catch 23pub fn mainSafe() void { 24 const config = readConfig("app.conf") catch |err| { 25 std.debug.print("Failed: {}\n", .{err}); 26 return; 27 }; 28 // ... 29}
Allocator Pattern (Explicit Effects)
zig1const std = @import("std"); 2 3// Function signature communicates: "I need to allocate" 4fn processData(allocator: std.mem.Allocator, input: []const u8) ![]u8 { 5 var result = try allocator.alloc(u8, input.len * 2); 6 errdefer allocator.free(result); // cleanup only on error path 7 8 // ... process into result 9 10 return result; // caller owns this memory 11} 12 13pub fn main() !void { 14 var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 15 defer _ = gpa.deinit(); 16 const allocator = gpa.allocator(); 17 18 const data = try processData(allocator, "input"); 19 defer allocator.free(data); // caller responsible for cleanup 20}
Tagged Unions (ADTs / Sum Types)
zig1const PaymentState = union(enum) { 2 pending: void, 3 processing: struct { transaction_id: []const u8 }, 4 completed: Receipt, 5 failed: PaymentError, 6 7 // Methods on the union 8 pub fn describe(self: PaymentState) []const u8 { 9 return switch (self) { 10 .pending => "Waiting for payment", 11 .processing => |p| p.transaction_id, 12 .completed => |r| r.summary, 13 .failed => |e| e.message, 14 }; 15 } 16}; 17 18// Exhaustive switch (compiler enforces all cases) 19fn handlePayment(state: PaymentState) void { 20 switch (state) { 21 .pending => startProcessing(), 22 .processing => |p| pollStatus(p.transaction_id), 23 .completed => |receipt| sendConfirmation(receipt), 24 .failed => |err| notifyFailure(err), 25 } 26}
Compile-Time Programming
zig1// comptime function for generics 2fn max(comptime T: type, a: T, b: T) T { 3 return if (a > b) a else b; 4} 5 6// Compile-time computed constants 7const LOOKUP_TABLE = blk: { 8 var table: [256]u8 = undefined; 9 for (&table, 0..) |*entry, i| { 10 entry.* = @intCast((i * 7) % 256); 11 } 12 break :blk table; 13}; 14 15// Generic container (like TypeScript generics) 16fn ArrayList(comptime T: type) type { 17 return struct { 18 items: []T, 19 allocator: std.mem.Allocator, 20 21 const Self = @This(); 22 23 pub fn init(allocator: std.mem.Allocator) Self { 24 return .{ .items = &[_]T{}, .allocator = allocator }; 25 } 26 27 pub fn append(self: *Self, item: T) !void { 28 // ... 29 } 30 }; 31}
Resource Management with defer
zig1fn processFile(allocator: std.mem.Allocator, path: []const u8) !void { 2 // Open file 3 const file = try std.fs.cwd().openFile(path, .{}); 4 defer file.close(); // ALWAYS runs on scope exit 5 6 // Allocate buffer 7 const buffer = try allocator.alloc(u8, 4096); 8 defer allocator.free(buffer); // cleanup guaranteed 9 10 // errdefer for conditional cleanup 11 var result = try allocator.alloc(u8, 1024); 12 errdefer allocator.free(result); // only on error 13 14 // If we reach here successfully, caller owns result 15 // ... 16}
Quick Reference
zig1// Imports 2const std = @import("std"); 3 4// Variables 5const immutable: u32 = 42; // prefer const 6var mutable: u32 = 0; // only when needed 7 8// Optionals (?T) - like Option/Maybe 9var maybe_value: ?u32 = null; 10const unwrapped = maybe_value orelse 0; // default value 11const ptr = maybe_value orelse return error.Missing; // early return 12 13// Error unions (!T) - like Result/Either 14fn canFail() !u32 { return error.SomeError; } 15const value = try canFail(); // propagate error 16const safe = canFail() catch |err| handleError(err); // catch error 17 18// Slices (pointer + length, not null-terminated) 19const slice: []const u8 = "hello"; // string literal is []const u8 20const arr: [5]u8 = .{ 1, 2, 3, 4, 5 }; 21const sub = arr[1..3]; // slice of array 22 23// Iteration 24for (slice, 0..) |byte, index| { } // value and index 25for (slice) |byte| { } // value only 26 27// Switch (exhaustive, can capture) 28switch (tagged_union) { 29 .variant => |captured| doSomething(captured), 30 else => {}, // or handle all cases 31} 32 33// Comptime 34const SIZE = comptime blk: { break :blk 64; }; 35fn generic(comptime T: type, val: T) T { return val; }
Detailed References
- references/idioms.md - Data-oriented design, memory patterns, testing
- references/async-io.md - New async/Io model, futures, cancellation
- references/c-interop.md - C FFI, @cImport, ABI compatibility
Forbidden Patterns
| ❌ Never | ✅ Instead |
|---|---|
| Global allocator / hidden malloc | Pass Allocator explicitly |
| Exceptions / panic for errors | Return error union !T |
| Null pointers without type | Use optional ?*T |
| Preprocessor macros | Use comptime and inline functions |
| C-style strings in Zig code | Use slices []const u8 |
| Ignoring errors silently | Handle with catch or propagate with try |
var when const works | Default to const, mutate only when necessary |
| Hidden control flow | Make all branches explicit |
| OOP inheritance hierarchies | Use composition and tagged unions |