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
const unless mutation required
- Function needs memory? → Accept
Allocator parameter, never global alloc
- Function can fail? → Return error union
!T, use try to propagate
- Handling an error? → Use
catch with explicit handler or try to propagate
- Need cleanup on exit? → Use
defer immediately after acquisition
- Cleanup only on error? → Use
errdefer for conditional cleanup
- Need generic code? → Use
comptime type parameters
- Compile-time known value? → Use
comptime to evaluate at build time
- Calling C code? → Use
@cImport for seamless FFI
- Need async I/O? → Pass
Io interface, use io.async() and future.await()
- Optimizing hot path? → Consider data-oriented design (SoA vs AoS)
Essential Patterns
Error Unions (Result Type Equivalent)
zig
1const 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)
zig
1const 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)
zig
1const 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
zig
1// 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
zig
1fn 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
zig
1// 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
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 |