| name | zig-best-practices |
| description | Provides Zig patterns for type-first development with tagged unions, explicit error sets, comptime validation, and memory management. Must use when reading or writing Zig files. |
Zig Best Practices
Type-First Development
Types define the contract before implementation. Follow this workflow:
- Define data structures - structs, unions, and error sets first
- Define function signatures - parameters, return types, and error unions
- Implement to satisfy types - let the compiler guide completeness
- Validate at comptime - catch invalid configurations during compilation
Make Illegal States Unrepresentable
Use Zig's type system to prevent invalid states at compile time.
Tagged unions for mutually exclusive states:
// Good: only valid combinations possible
const RequestState = union(enum) {
idle,
loading,
success: []const u8,
failure: anyerror,
};
fn handleState(state: RequestState) void {
switch (state) {
.idle => {},
.loading => showSpinner(),
.success => |data| render(data),
.failure => |err| showError(err),
}
}
// Bad: allows invalid combinations
const RequestState = struct {
loading: bool,
data: ?[]const u8,
err: ?anyerror,
};
Explicit error sets for failure modes:
// Good: documents exactly what can fail
const ParseError = error{
InvalidSyntax,
UnexpectedToken,
EndOfInput,
};
fn parse(input: []const u8) ParseError!Ast {
// implementation
}
// Bad: anyerror hides failure modes
fn parse(input: []const u8) anyerror!Ast {
// implementation
}
Distinct types for domain concepts:
// Prevent mixing up IDs of different types
const UserId = enum(u64) { _ };
const OrderId = enum(u64) { _ };
fn getUser(id: UserId) !User {
// Compiler prevents passing OrderId here
}
fn createUserId(raw: u64) UserId {
return @enumFromInt(raw);
}
Comptime validation for invariants:
fn Buffer(comptime size: usize) type {
if (size == 0) {
@compileError("buffer size must be greater than 0");
}
if (size > 1024 * 1024) {
@compileError("buffer size exceeds 1MB limit");
}
return struct {
data: [size]u8 = undefined,
len: usize = 0,
};
}
Non-exhaustive enums for extensibility:
// External enum that may gain variants
const Status = enum(u8) {
active = 1,
inactive = 2,
pending = 3,
_,
};
fn processStatus(status: Status) !void {
switch (status) {
.active => {},
.inactive => {},
.pending => {},
_ => return error.UnknownStatus,
}
}
Module Structure
Larger cohesive files are idiomatic in Zig. Keep related code together: tests alongside implementation, comptime generics at file scope, public/private controlled by pub. Split only when a file handles genuinely separate concerns. The standard library demonstrates this pattern with files like std/mem.zig containing 2000+ lines of cohesive memory operations.
Instructions
- Return errors with context using error unions (
!T); every function returns a value or an error. Explicit error sets document failure modes. - Use
errdeferfor cleanup on error paths; usedeferfor unconditional cleanup. This prevents resource leaks without try-finally boilerplate. - Handle all branches in
switchstatements; include anelseclause that returns an error or usesunreachablefor truly impossible cases. - Pass allocators explicitly to functions requiring dynamic memory; prefer
std.testing.allocatorin tests for leak detection. - Prefer
constovervar; prefer slices over raw pointers for bounds safety. Immutability signals intent and enables optimizations. - Avoid
anytype; prefer explicitcomptime T: typeparameters. Explicit types document intent and produce clearer error messages. - Use
std.log.scopedfor namespaced logging; define a module-levellogconstant for consistent scope across the file. - Add or update tests for new logic; use
std.testing.allocatorto catch memory leaks automatically.
Examples
Explicit failure for unimplemented logic:
fn buildWidget(widget_type: []const u8) !Widget {
return error.NotImplemented;
}
Propagate errors with try:
fn readConfig(path: []const u8) !Config {
const file = try std.fs.cwd().openFile(path, .{});
defer file.close();
const contents = try file.readToEndAlloc(allocator, max_size);
return parseConfig(contents);
}
Resource cleanup with errdefer:
fn createResource(allocator: std.mem.Allocator) !*Resource {
const resource = try allocator.create(Resource);
errdefer allocator.destroy(resource);
resource.* = try initializeResource();
return resource;
}
Exhaustive switch with explicit default:
fn processStatus(status: Status) ![]const u8 {
return switch (status) {
.active => "processing",
.inactive => "skipped",
_ => error.UnhandledStatus,
};
}
Testing with memory leak detection:
const std = @import("std");
test "widget creation" {
const allocator = std.testing.allocator;
var list: std.ArrayListUnmanaged(u32) = .empty;
defer list.deinit(allocator);
try list.append(allocator, 42);
try std.testing.expectEqual(1, list.items.len);
}
Memory Management
- Pass allocators explicitly; never use global state for allocation. Functions declare their allocation needs in parameters.
- Use
deferimmediately after acquiring a resource. Place cleanup logic next to acquisition for clarity. - Prefer arena allocators for temporary allocations; they free everything at once when the arena is destroyed.
- Use
std.testing.allocatorin tests; it reports leaks with stack traces showing allocation origins.
Examples
Allocator as explicit parameter:
fn processData(allocator: std.mem.Allocator, input: []const u8) ![]u8 {
const result = try allocator.alloc(u8, input.len * 2);
errdefer allocator.free(result);
// process input into result
return result;
}
Arena allocator for batch operations:
fn processBatch(items: []const Item) !void {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
const allocator = arena.allocator();
for (items) |item| {
const processed = try processItem(allocator, item);
try outputResult(processed);
}
// All allocations freed when arena deinits
}
Logging
- Use
std.log.scopedto create namespaced loggers; each module should define its own scoped logger for filtering. - Define a module-level
const logat the top of the file; use it consistently throughout the module. - Use appropriate log levels:
errfor failures,warnfor suspicious conditions,infofor state changes,debugfor tracing.
Examples
Scoped logger for a module:
const std = @import("std");
const log = std.log.scoped(.widgets);
pub fn createWidget(name: []const u8) !Widget {
log.debug("creating widget: {s}", .{name});
const widget = try allocateWidget(name);
log.debug("created widget id={d}", .{widget.id});
return widget;
}
pub fn deleteWidget(id: u32) void {
log.info("deleting widget id={d}", .{id});
// cleanup
}
Multiple scopes in a codebase:
// In src/db.zig
const log = std.log.scoped(.db);
// In src/http.zig
const log = std.log.scoped(.http);
// In src/auth.zig
const log = std.log.scoped(.auth);
Comptime Patterns
- Use
comptimeparameters for generic functions; type information is available at compile time with zero runtime cost. - Prefer compile-time validation over runtime checks when possible. Catch errors during compilation rather than in production.
- Use
@compileErrorfor invalid configurations that should fail the build.
Examples
Generic function with comptime type:
fn max(comptime T: type, a: T, b: T) T {
return if (a > b) a else b;
}
Compile-time validation:
fn createBuffer(comptime size: usize) [size]u8 {
if (size == 0) {
@compileError("buffer size must be greater than 0");
}
return [_]u8{0} ** size;
}
Avoiding anytype
- Prefer
comptime T: typeoveranytype; explicit type parameters document expected constraints and produce clearer errors. - Use
anytypeonly when the function genuinely accepts any type (likestd.debug.print) or for callbacks/closures. - When using
anytype, add a doc comment describing the expected interface or constraints.
Examples
Prefer explicit comptime type (good):
fn sum(comptime T: type, items: []const T) T {
var total: T = 0;
for (items) |item| {
total += item;
}
return total;
}
Avoid anytype when type is known (bad):
// Unclear what types are valid; error messages will be confusing
fn sum(items: anytype) @TypeOf(items[0]) {
// ...
}
Acceptable anytype for callbacks:
/// Calls `callback` for each item. Callback must accept (T) and return void.
fn forEach(comptime T: type, items: []const T, callback: anytype) void {
for (items) |item| {
callback(item);
}
}
Using @TypeOf when anytype is necessary:
fn debugPrint(value: anytype) void {
const T = @TypeOf(value);
if (@typeInfo(T) == .Pointer) {
std.debug.print("ptr: {*}\n", .{value});
} else {
std.debug.print("val: {}\n", .{value});
}
}
Error Handling Patterns
- Define specific error sets for functions; avoid
anyerrorwhen possible. Specific errors document failure modes. - Use
catchwith a block for error recovery or logging; usecatch unreachableonly when errors are truly impossible. - Merge error sets with
||when combining operations that can fail in different ways.
Examples
Specific error set:
const ConfigError = error{
FileNotFound,
ParseError,
InvalidFormat,
};
fn loadConfig(path: []const u8) ConfigError!Config {
// implementation
}
Error handling with catch block:
const value = operation() catch |err| {
std.log.err("operation failed: {}", .{err});
return error.OperationFailed;
};
Configuration
- Load config from environment variables at startup; validate required values before use. Missing config should cause a clean exit with a descriptive message.
- Define a Config struct as single source of truth; avoid
std.posix.getenvscattered throughout code. - Use sensible defaults for development; require explicit values for production secrets.
Examples
Typed config struct:
const std = @import("std");
pub const Config = struct {
port: u16,
database_url: []const u8,
api_key: []const u8,
env: []const u8,
};
pub fn loadConfig() !Config {
const db_url = std.posix.getenv("DATABASE_URL") orelse
return error.MissingDatabaseUrl;
const api_key = std.posix.getenv("API_KEY") orelse
return error.MissingApiKey;
const port_str = std.posix.getenv("PORT") orelse "3000";
const port = std.fmt.parseInt(u16, port_str, 10) catch
return error.InvalidPort;
return .{
.port = port,
.database_url = db_url,
.api_key = api_key,
.env = std.posix.getenv("ENV") orelse "development",
};
}
Optionals
- Use
orelseto provide default values for optionals; use.?only when null is a program error. - Prefer
if (optional) |value|pattern for safe unwrapping with access to the value.
Examples
Safe optional handling:
fn findWidget(id: u32) ?*Widget {
// lookup implementation
}
fn processWidget(id: u32) !void {
const widget = findWidget(id) orelse return error.WidgetNotFound;
try widget.process();
}
Optional with if unwrapping:
if (maybeValue) |value| {
try processValue(value);
} else {
std.log.warn("no value present", .{});
}
Advanced Topics
Reference these guides for specialized patterns:
- Building custom containers (queues, stacks, trees): See GENERICS.md
- Interfacing with C libraries (raylib, SDL, curl, system APIs): See C-INTEROP.md
- Debugging memory leaks (GPA, stack traces): See DEBUGGING.md
References
- Language Reference: https://ziglang.org/documentation/0.15.2/
- Standard Library: https://ziglang.org/documentation/0.15.2/std/
- Code Samples: https://ziglang.org/learn/samples/
- Zig Guide: https://zig.guide/