| name | zig-expert |
| description | Write idiomatic Zig 0.15.2 (latest stable) code following the Zen of Zig philosophy. Use for systems programming with Zig (.zig files), covering manual memory management with allocators, error unions and explicit error handling, compile-time programming (comptime), data-oriented design, and the 0.15.2 I/O model. Applies functional programming parallels (Result types, ADTs, explicit effects) for C-free systems development. |
Idiomatic Zig 0.15.2 Programming
Expert guidance for writing idiomatic Zig code targeting the latest stable release (0.15.2). Embodies the Zen of Zig: explicit intent, no hidden control flow, and compile-time over runtime.
IMPORTANT: This skill targets Zig 0.15.2 stable. Do NOT use nightly 0.16+ features such as the colorblind async/Io model (io.async(), future.await(), pub fn main(io: std.Io) !void). Those APIs do not exist in 0.15.2.
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 parameter (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
- Writing output? - Accept
*std.Io.Writer parameter for injectable I/O
- Reading a file? - Prefer
readFileAlloc for slurping, cursor-based parsing for binary formats
- Optimizing hot path? - Consider data-oriented design (SoA vs AoS)
Zig 0.15.2 API Quick Reference
Build System (0.15.2)
// build.zig - uses createModule/root_module API
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const exe_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
.link_libc = true, // replaces exe.linkLibC()
});
const exe = b.addExecutable(.{
.name = "myapp",
.root_module = exe_module,
});
b.installArtifact(exe);
}
Key changes from older Zig:
b.path("...") instead of .{ .path = "..." }
b.createModule(...) + .root_module instead of inline .root_source_file on executable
.link_libc = true on the module, not exe.linkLibC()
I/O (0.15.2)
// Buffered file writer
var buf: [4096]u8 = undefined;
var writer = file.writer(&buf);
try writer.interface.writeAll("hello");
try writer.interface.flush();
// Stdout/stderr
var stdout_buf: [4096]u8 = undefined;
var stdout = std.fs.File.stdout().writer(&stdout_buf);
// Fixed-buffer writer (no file, writes to memory)
var out_buf: [512]u8 = undefined;
var writer = std.Io.Writer.fixed(&out_buf);
// Injectable writer parameter
fn generate(stdout: *std.Io.Writer, stderr: *std.Io.Writer) !void {
try stdout.writeAll("output\n");
try stdout.flush();
}
Collections (0.15.2)
// ArrayList - allocator passed per-call, not at init
var list: std.ArrayList(u8) = .empty;
defer list.deinit(allocator);
try list.append(allocator, 42);
try list.appendSlice(allocator, "hello");
const owned = try list.toOwnedSlice(allocator);
Sorting (0.15.2)
// std.sort.pdq replaces std.sort.sort
std.sort.pdq(MyType, slice, {}, struct {
fn lessThan(_: void, a: MyType, b: MyType) bool {
return a.value < b.value;
}
}.lessThan);
PRNG (0.15.2)
// std.Random.DefaultPrng replaces std.rand.DefaultPrng
var prng = std.Random.DefaultPrng.init(seed);
const rnd = prng.random();
File Reading (0.15.2)
// Slurp entire file into memory
const bytes = try std.fs.cwd().readFileAlloc(allocator, path, 128 * 1024 * 1024);
defer allocator.free(bytes);
// Cursor-based parsing over byte slice (replaces streaming reader)
var cursor: usize = 0;
const header = try takeBytes(bytes, &cursor, @sizeOf(Header));
Overflow-Checked Arithmetic
// Use std.math.mul for size computations
const count = try std.math.mul(usize, rows, cols);
const buf = try allocator.alloc(f32, count);
Essential Patterns
Error Unions (Result Type Equivalent)
const FileError = error{ NotFound, PermissionDenied, InvalidPath };
fn readConfig(path: []const u8) FileError!Config {
const file = std.fs.cwd().openFile(path, .{}) catch |err| {
return switch (err) {
error.FileNotFound => error.NotFound,
error.AccessDenied => error.PermissionDenied,
else => error.InvalidPath,
};
};
defer file.close();
// ... parse config
return config;
}
// Propagate with try (like Rust's ?)
pub fn main() !void {
const config = try readConfig("app.conf");
// ...
}
// Handle explicitly with catch
pub fn mainSafe() void {
const config = readConfig("app.conf") catch |err| {
std.debug.print("Failed: {}\n", .{err});
return;
};
_ = config;
}
Allocator Pattern (Explicit Effects)
const std = @import("std");
// Function signature communicates: "I need to allocate"
fn processData(allocator: std.mem.Allocator, input: []const u8) ![]u8 {
const result = try allocator.alloc(u8, input.len * 2);
errdefer allocator.free(result); // cleanup only on error path
// ... process into result
return result; // caller owns this memory
}
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const data = try processData(allocator, "input");
defer allocator.free(data); // caller responsible for cleanup
}
Tagged Unions (ADTs / Sum Types)
const PaymentState = union(enum) {
pending: void,
processing: struct { transaction_id: []const u8 },
completed: Receipt,
failed: PaymentError,
// Methods on the union
pub fn describe(self: PaymentState) []const u8 {
return switch (self) {
.pending => "Waiting for payment",
.processing => |p| p.transaction_id,
.completed => |r| r.summary,
.failed => |e| e.message,
};
}
};
// Exhaustive switch (compiler enforces all cases)
fn handlePayment(state: PaymentState) void {
switch (state) {
.pending => startProcessing(),
.processing => |p| pollStatus(p.transaction_id),
.completed => |receipt| sendConfirmation(receipt),
.failed => |err| notifyFailure(err),
}
}
Compile-Time Programming
// comptime function for generics
fn max(comptime T: type, a: T, b: T) T {
return if (a > b) a else b;
}
// Compile-time computed constants
const LOOKUP_TABLE = blk: {
var table: [256]u8 = undefined;
for (&table, 0..) |*entry, i| {
entry.* = @intCast((i * 7) % 256);
}
break :blk table;
};
// Generic container (like TypeScript generics)
fn ArrayList(comptime T: type) type {
return struct {
items: []T,
allocator: std.mem.Allocator,
const Self = @This();
pub fn init(allocator: std.mem.Allocator) Self {
return .{ .items = &[_]T{}, .allocator = allocator };
}
pub fn append(self: *Self, item: T) !void {
// ...
_ = item;
}
};
}
Resource Management with defer
fn processFile(allocator: std.mem.Allocator, path: []const u8) !void {
// Open file
const file = try std.fs.cwd().openFile(path, .{});
defer file.close(); // ALWAYS runs on scope exit
// Allocate buffer
const buffer = try allocator.alloc(u8, 4096);
defer allocator.free(buffer); // cleanup guaranteed
// errdefer for conditional cleanup
const result = try allocator.alloc(u8, 1024);
errdefer allocator.free(result); // only on error
// If we reach here successfully, caller owns result
_ = result;
}
Quick Reference
// Imports
const std = @import("std");
// Variables
const immutable: u32 = 42; // prefer const
var mutable: u32 = 0; // only when needed
// Optionals (?T) - like Option/Maybe
var maybe_value: ?u32 = null;
const unwrapped = maybe_value orelse 0; // default value
const ptr = maybe_value orelse return error.Missing; // early return
// Error unions (!T) - like Result/Either
fn canFail() !u32 { return error.SomeError; }
const value = try canFail(); // propagate error
const safe = canFail() catch |err| handleError(err); // catch error
// Slices (pointer + length, not null-terminated)
const slice: []const u8 = "hello"; // string literal is []const u8
const arr: [5]u8 = .{ 1, 2, 3, 4, 5 };
const sub = arr[1..3]; // slice of array
// Iteration
for (slice, 0..) |byte, index| { _ = byte; _ = index; } // value and index
for (slice) |byte| { _ = byte; } // value only
// Switch (exhaustive, can capture)
switch (tagged_union) {
.variant => |captured| doSomething(captured),
else => {}, // or handle all cases
}
// Comptime
const SIZE = comptime blk: { break :blk 64; };
fn generic(comptime T: type, val: T) T { return val; }
// Wrapping arithmetic (for hash functions, PRNG, etc.)
const result = a *% b; // wrapping multiply
const result2 = a +% b; // wrapping add
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 |
std.sort.sort (removed) | Use std.sort.pdq |
std.rand.DefaultPrng (removed) | Use std.Random.DefaultPrng |
std.ArrayList(T).init(alloc) (old API) | Use .empty with per-call allocator |
std.io.bufferedReader/Writer (old API) | Use file.writer(&buf) with .interface |
| Unchecked size arithmetic | Use std.math.mul for overflow safety |
| Nightly 0.16+ async features | Not available in 0.15.2 stable |
0.15.2 Best Practices
-
Build with createModule: Always use b.createModule() + .root_module in build.zig. The old root_source_file on executables is removed.
-
Injectable I/O: Accept *std.Io.Writer parameters instead of calling std.fs.File.stdout() directly in library code. This makes functions testable and composable.
-
Cursor-based binary parsing: For binary formats, readFileAlloc the whole file into a byte slice, then parse with a cursor index. This is simpler and faster than streaming readers.
-
Proper errdefer chains: When a function allocates multiple resources, add errdefer allocator.free(...) after each allocation. Do not batch allocations without cleanup paths.
-
Overflow-checked arithmetic: Use std.math.mul(usize, a, b) for any size computation that multiplies user-controlled or file-derived values. Plain * can silently overflow.
-
Const correctness: Use const for every binding that is not mutated. The compiler enforces this, but getting it right on the first pass avoids noisy diffs.
-
Explicit flush: Buffered writers (file.writer(&buf)) require an explicit interface.flush() call. Forgetting this silently drops output.
-
Arena allocators for batch work: When loading a complex structure (adapter files, configs), use ArenaAllocator so the entire structure can be freed in one call.