Zig Fundamentals
Overview
This skill provides essential Zig language concepts for experienced programmers transitioning from other languages. It focuses on Zig's distinctive features: comptime (compile-time execution), error handling, optionals, and memory control primitives.
Target Zig version: 0.15.2
Core Concepts
Comptime: Compile-Time Execution
Comptime is Zig's most distinctive feature, enabling code execution during compilation.
What it does:
- Executes arbitrary Zig code at compile time
- Generates code, types, and data structures before runtime
- Eliminates need for macros, preprocessor, or code generation tools
Common use cases:
- Generic functions and types:
``zig fn max(comptime T: type, a: T, b: T) T { return if (a > b) a else b; } ` The comptime T: type` means T is determined at compile time, creating monomorphized versions.
- Comptime computation:
``zig const arraysize = comptime fibonacci(10); // Computed at compile time var buffer: [arraysize]u8 = undefined; ``
- Type introspection and generation:
``zig fn printFields(comptime T: type) void { inline for (@typeInfo(T).Struct.fields) |field| { std.debug.print("{s}\n", .{field.name}); } } ``
Key points:
- Any expression can be forced to compile-time with
comptime keyword
- Parameters marked
comptime must be known at compile time
- Use
inline for to unroll loops at compile time
- Access type information with
@typeInfo(T)
For more detailed patterns, see references/comptime-patterns.md.
Error Handling
Zig uses explicit error handling without exceptions. Errors are values, not control flow.
Error unions:
const FileError = error{ FileNotFound, PermissionDenied };
fn readFile(path: []const u8) FileError![]u8 {
// Returns either error or []u8
if (file_not_exist) return error.FileNotFound;
return file_contents;
}
The ! operator:
!T means "error union" - either an error or value of type T
anyerror!T allows any error type
Error handling patterns:
try - propagate errors:
``zig const contents = try readFile("data.txt"); // If error, returns immediately from current function ``
catch - handle errors:
``zig const contents = readFile("data.txt") catch |err| { std.debug.print("Error: {}\n", .{err}); return; }; ``
catch with default value:
``zig const contents = readFile("data.txt") catch "default content"; ``
errdefer - cleanup on error:
```zig fn allocateResource() !*Resource { const resource = try allocate(); errdefer deallocate(resource); // Only runs if function returns error
try initialize(resource); return resource; } ```
Key principles:
- Errors are compile-time known sets of values
- No hidden control flow (no exceptions)
try is syntactic sugar for catch |err| return err
- Always handle or propagate errors explicitly
Optionals
Optionals represent values that may or may not exist, replacing null pointers safely.
Syntax:
const maybe_value: ?i32 = null; // Optional i32
const has_value: ?i32 = 42; // Contains value
Checking and unwrapping:
if unwrapping:
``zig if (maybe_value) |value| { // value is i32 here, not ?i32 std.debug.print("Value: {}\n", .{value}); } else { std.debug.print("No value\n", .{}); } ``
orelse - provide default:
``zig const value = maybe_value orelse 0; // Use 0 if null ``
orelse - early return:
``zig const value = maybe_value orelse return; // Return if null ``
.? - assert non-null (unsafe):
``zig const value = maybe_value.?; // Panics if null ``
Combining with errors:
fn findUser(id: u32) !?User {
// Can return error OR null OR User
if (database_error) return error.DatabaseDown;
if (user_not_found) return null;
return user;
}
// Usage:
const user = try findUser(123) orelse return error.UserNotFound;
Key distinction from other languages:
null only exists for optional types (?T)
- Regular pointers cannot be null in Zig
- Optional pointers have same size as regular pointers (null is optimized)
Defer: Guaranteed Cleanup
defer executes code when scope exits, regardless of how it exits (return, error, etc.).
Basic usage:
fn processFile(path: []const u8) !void {
const file = try std.fs.cwd().openFile(path, .{});
defer file.close(); // Guaranteed to run when function exits
// Work with file...
// file.close() automatically called
}
Multiple defers execute in reverse order (LIFO):
defer std.debug.print("Third\n", .{});
defer std.debug.print("Second\n", .{});
defer std.debug.print("First\n", .{});
// Prints: First, Second, Third
errdefer - cleanup only on error:
fn createWidget() !*Widget {
const widget = try allocator.create(Widget);
errdefer allocator.destroy(widget); // Only if function errors
try widget.initialize(); // If this fails, widget is destroyed
return widget;
}
Key patterns:
- Use
defer immediately after acquiring resource
errdefer for partial initialization cleanup
- Defers execute in reverse order of declaration
- Scope-based, not function-based (works in blocks)
Basic Types and Values
Integer types:
- Explicit bit sizes:
i8, u8, i16, u16, i32, u32, i64, u64, i128, u128
- Platform-dependent:
isize, usize (pointer-sized integers)
- Arbitrary bit width:
i7, u24, etc. (any bit count)
Float types:
Boolean:
bool - only true or false
- No implicit integer conversion
Arrays and slices:
const array: [5]i32 = .{ 1, 2, 3, 4, 5 }; // Fixed-size array
const slice: []const i32 = &array; // Slice references array
Struct initialization:
const Point = struct {
x: f32,
y: f32,
};
const point = Point{ .x = 1.0, .y = 2.0 };
Anonymous struct literals:
const point = .{ .x = 1.0, .y = 2.0 }; // Type inferred
Undefined and initialization:
undefined - uninitialized memory (performance optimization)
- Use for arrays/buffers that will be filled immediately
- Debug builds catch usage of undefined values
Control Flow Differences
if is an expression:
const value = if (condition) 42 else 0;
switch is exhaustive:
const value: i32 = 2;
const result = switch (value) {
1 => "one",
2 => "two",
3, 4, 5 => "three to five",
else => "other",
};
while with continue expressions:
var i: usize = 0;
while (i < 10) : (i += 1) { // Continue expression runs each iteration
// Loop body
}
for for arrays and slices:
for (items) |item| {
std.debug.print("{}\n", .{item});
}
// With index:
for (items, 0..) |item, i| {
std.debug.print("{}: {}\n", .{ i, item });
}
Working with Zig Code
Reading Zig Code
When examining Zig code:
- Look for
comptime - indicates compile-time logic
- Check error types - functions with
! return errors
- Identify optionals -
?T types may be null
- Find defers - locate resource cleanup
- Read parameter types -
comptime params vs runtime params
Common Patterns
Result type (error + optional):
fn operation() !?Value {
// Returns: error, null, or Value
}
Allocator parameter:
fn createThing(allocator: std.mem.Allocator) !Thing {
const data = try allocator.alloc(u8, size);
// allocator passed explicitly, no hidden allocation
}
Payload capture:
if (maybe_value) |value| {
// Use value
}
if (result) |value| {
// Success case
} else |err| {
// Error case
}
Transitioning from Other Languages
From C/C++:
- No implicit casts - use explicit conversion (
@intCast, etc.)
- No undefined behavior in safe mode (checked in Debug/ReleaseSafe)
- No hidden control flow (no exceptions, no operator overloading)
- Comptime replaces templates/macros
From Rust:
- No borrow checker - manual memory management with allocators
- Explicit allocator passing instead of implicit lifetimes
- Error unions instead of Result<T, E> enum
- Optionals instead of Option<T> enum
From Go/Java/Python:
- No garbage collector - manual memory management
- Explicit error handling, no exceptions
- No hidden allocations - all memory explicit
- Value types by default, not references
Additional Resources
Reference Files
For detailed examples and advanced patterns:
references/comptime-patterns.md - Advanced comptime techniques, type introspection, generic patterns
references/error-patterns.md - Comprehensive error handling patterns, error sets, custom errors
Official Documentation
Quick Reference
| Concept |
Syntax |
Purpose |
| Comptime parameter |
comptime T: type |
Generic/compile-time value |
| Error union |
!T |
Function may return error or T |
| Optional |
?T |
Value may be null |
| Unwrap optional |
value orelse default |
Provide default if null |
| Try error |
try expr |
Propagate error or unwrap value |
| Catch error |
expr catch handler |
Handle error case |
| Defer cleanup |
defer expr |
Execute when scope exits |
| Error defer |
errdefer expr |
Execute only on error |
| Type introspection |
@typeInfo(T) |
Get compile-time type info |
Tips for Success
- Embrace explicit error handling - no hidden exceptions improves reliability
- Use comptime for generics - replaces C++ templates with readable code
- Leverage defer for cleanup - simpler than try/finally or RAII
- Pass allocators explicitly - makes memory allocation visible
- Read the standard library - idiomatic patterns and common utilities
- Use Debug build mode - catches undefined behavior and safety violations
When working with Zig code, focus on understanding the explicit flow: where errors originate, how memory is allocated, and what executes at compile time versus runtime.