NexusCS

Zig

Systems programming
Quick reference for Zig programming language - a general-purpose systems language focused on robustness, optimal performance, and maintainability with compile-time execution and seamless C interop.
featured

Getting started

Hello World

const std = @import("std");

pub fn main() !void {
    const stdout = std.io.getStdOut().writer();
    try stdout.print("Hello, {s}!\n", .{"World"});
}

Build and run:

zig build-exe hello.zig
./hello

Installation

# macOS
brew install zig

# Linux (tarball)
wget https://ziglang.org/download/latest
tar xf zig-linux-*.tar.xz
export PATH=$PATH:~/zig

# Windows (Scoop)
scoop install zig

Verify installation:

zig version              # Show Zig version
zig zen                  # Display Zen of Zig

Variables

const x = 5;             // Immutable (like const in Rust)
var y = 10;              // Mutable
const z: i32 = 15;       // Explicit type

// ⚠️ const means compile-time or runtime immutable
// NOT C's "const" (which is just a hint)

Basic types

Integers

// Signed integers
i8, i16, i32, i64, i128  // Fixed width
isize                     // Pointer-sized

// Unsigned integers
u8, u16, u32, u64, u128
usize                     // Pointer-sized

// Arbitrary bit-width ⚠️ Zig-specific!
i3, u7, i24, u47         // Any bit width

Floats

f16, f32, f64, f128      // IEEE-754 floats

const pi: f32 = 3.14;
const e: f64 = 2.71828;

Other primitives

bool                     // true or false
void                     // Zero-size type
noreturn                 // For functions that never return
type                     // Type of types
anyerror                 // Global error set
comptime_int             // Compile-time integers
comptime_float           // Compile-time floats

Functions

Basic functions

fn add(a: i32, b: i32) i32 {
    return a + b;
}

// ⚠️ No function overloading (use generics)

Error unions

fn divide(a: i32, b: i32) !i32 {
    if (b == 0) return error.DivisionByZero;
    return @divTrunc(a, b);
}

// !i32 means "error union of error and i32"
// Like Result<i32, Error> in Rust

Multiple return values

fn divmod(a: i32, b: i32) struct { quot: i32, rem: i32 } {
    return .{
        .quot = @divTrunc(a, b),
        .rem = @rem(a, b),
    };
}

const result = divmod(10, 3);
// result.quot == 3, result.rem == 1

Generic functions

fn max(comptime T: type, a: T, b: T) T {
    return if (a > b) a else b;
}

const x = max(i32, 5, 10);       // x = 10
const y = max(f64, 3.14, 2.71);  // y = 3.14

Error handling

Try and catch

// try unwraps or propagates error
const result = try divide(10, 2);

// catch handles error
const result = divide(10, 0) catch 0;
const result = divide(10, 0) catch |err| {
    std.debug.print("Error: {}\n", .{err});
    return err;
};

Error sets

const FileError = error{
    NotFound,
    PermissionDenied,
    OutOfMemory,
};

fn readFile(path: []const u8) FileError![]u8 {
    // ...
    return error.NotFound;
}

Defer and errdefer

fn doWork() !void {
    const file = try std.fs.cwd().openFile("data.txt", .{});
    defer file.close();              // Always runs

    const buf = try allocator.alloc(u8, 1024);
    errdefer allocator.free(buf);    // Runs only on error

    // Work with file and buffer
    try processData(buf);
}

// ⚠️ defer runs in reverse order (like C++ destructors)

Optionals

Optional types

const maybe_num: ?i32 = null;    // Optional (like Option<i32>)
const value: ?i32 = 42;

// Unwrap with orelse
const x = maybe_num orelse 0;    // x = 0

// Unwrap with if
if (maybe_num) |num| {
    // num is unwrapped i32
    std.debug.print("Value: {}\n", .{num});
}

Optional pointers

const ptr: ?*i32 = null;

// Check and unwrap
if (ptr) |p| {
    p.* = 42;
}

// ⚠️ Non-optional pointers are NEVER null
// Use ?*T for nullable pointers

Combining optionals and errors

fn findUser(id: u32) !?User {
    // Returns error, null, or User
    const user = try database.query(id);
    if (user.id == 0) return null;
    return user;
}

// Unwrap both error and optional
if (findUser(42)) |user| {
    // user is User
} else |err| {
    // err is error
}

Memory management

Allocators

const std = @import("std");

// General purpose allocator
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();

// Arena (frees all at once)
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();

// Fixed buffer
var buffer: [1024]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buffer);

Allocation

// Allocate single item
const ptr = try allocator.create(i32);
defer allocator.destroy(ptr);
ptr.* = 42;

// Allocate slice
const slice = try allocator.alloc(i32, 100);
defer allocator.free(slice);

// Allocate with alignment
const aligned = try allocator.alignedAlloc(u8, 16, 256);
defer allocator.free(aligned);

Common allocators

Allocator Use Case Free Strategy
page_allocator Large allocations Individual free
GeneralPurposeAlloc Default (with safety) Individual free
ArenaAllocator Batch operations Free all at once
FixedBufferAllocator Stack-like, no heap No free needed
c_allocator C interop Compatible with C

Arrays and slices

Arrays

const arr = [5]i32{ 1, 2, 3, 4, 5 };   // Fixed size
const len = arr.len;                    // 5

// Initialize with repeated value
const zeros = [_]i32{0} ** 100;        // 100 zeros

// Multi-dimensional
const matrix = [3][3]i32{
    [_]i32{ 1, 2, 3 },
    [_]i32{ 4, 5, 6 },
    [_]i32{ 7, 8, 9 },
};

Slices

const arr = [_]i32{ 1, 2, 3, 4, 5 };
const slice: []const i32 = arr[1..4];  // [2, 3, 4]

// Slices are fat pointers: pointer + length
// Like std::span<T> in C++20

var dynamic = try allocator.alloc(i32, 10);
defer allocator.free(dynamic);         // ⚠️ Must free!

String literals

const str: []const u8 = "Hello";       // String literal
const multiline =
    \\Line 1
    \\Line 2
    \\Line 3
;

// ⚠️ Strings are just []const u8 (like C)
// No std::string, use ArrayList(u8)

Structs and enums

Structs

const Point = struct {
    x: i32,
    y: i32,

    // Method (just a namespaced function)
    pub fn distance(self: Point, other: Point) f32 {
        const dx = @as(f32, @floatFromInt(other.x - self.x));
        const dy = @as(f32, @floatFromInt(other.y - self.y));
        return @sqrt(dx * dx + dy * dy);
    }
};

const p1 = Point{ .x = 0, .y = 0 };
const p2 = Point{ .x = 3, .y = 4 };
const dist = p1.distance(p2);         // 5.0

Enums

const Color = enum {
    red,
    green,
    blue,

    // Enum methods
    pub fn isRed(self: Color) bool {
        return self == .red;
    }
};

const c: Color = .red;                // Type inferred

Tagged unions

const Payload = union(enum) {
    int: i32,
    float: f64,
    boolean: bool,
};

const p = Payload{ .int = 42 };

switch (p) {
    .int => |value| std.debug.print("int: {}\n", .{value}),
    .float => |value| std.debug.print("float: {}\n", .{value}),
    .boolean => |value| std.debug.print("bool: {}\n", .{value}),
}

// ⚠️ switch must be exhaustive (no default needed if all covered)

Comptime

Compile-time execution

// Compute at compile time
const array_size = comptime fibonacci(10);
var array: [array_size]i32 = undefined;

fn fibonacci(n: u32) u32 {
    if (n <= 1) return n;
    return fibonacci(n - 1) + fibonacci(n - 2);
}

// ⚠️ comptime guarantees compile-time evaluation

Generic data structures

fn List(comptime T: type) type {
    return struct {
        items: []T,
        allocator: std.mem.Allocator,

        pub fn init(allocator: std.mem.Allocator) !List(T) {
            return List(T){
                .items = &[_]T{},
                .allocator = allocator,
            };
        }

        pub fn deinit(self: *List(T)) void {
            self.allocator.free(self.items);
        }
    };
}

var int_list = try List(i32).init(allocator);
defer int_list.deinit();

Type reflection

const std = @import("std");

fn printType(comptime T: type) void {
    const info = @typeInfo(T);
    std.debug.print("Type: {}\n", .{@typeName(T)});

    switch (info) {
        .Int => |int_info| {
            std.debug.print("Bits: {}\n", .{int_info.bits});
        },
        .Struct => |struct_info| {
            std.debug.print("Fields: {}\n", .{struct_info.fields.len});
        },
        else => {},
    }
}

Testing

Basic tests

const std = @import("std");

test "addition" {
    try std.testing.expect(2 + 2 == 4);
}

test "allocator" {
    const allocator = std.testing.allocator;
    const memory = try allocator.alloc(u8, 100);
    defer allocator.free(memory);

    try std.testing.expect(memory.len == 100);
}

Run tests:

zig test file.zig        # Run all tests
zig test file.zig --test-filter "addition"  # Specific test

Test assertions

Function Purpose
expect(condition) Assert boolean
expectEqual(expected, actual) Compare values
expectError(err, result) Expect specific error
expectEqualStrings(a, b) Compare strings
expectApproxEqAbs(a, b, tol) Float comparison

Build system

build.zig

const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    const exe = b.addExecutable(.{
        .name = "myapp",
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
    });

    b.installArtifact(exe);

    const run_cmd = b.addRunArtifact(exe);
    const run_step = b.step("run", "Run the app");
    run_step.dependOn(&run_cmd.step);
}

Build commands

zig build                # Build project
zig build run            # Build and run
zig build test           # Run tests
zig build -Doptimize=ReleaseFast  # Optimized build

Cross-compilation

# Build for different targets
zig build -Dtarget=x86_64-linux
zig build -Dtarget=aarch64-macos
zig build -Dtarget=x86_64-windows

# List all targets
zig targets

C interop

Import C headers

const c = @cImport({
    @cInclude("stdio.h");
    @cInclude("stdlib.h");
});

pub fn main() void {
    _ = c.printf("Hello from C!\n");
}

Export to C

export fn add(a: i32, b: i32) i32 {
    return a + b;
}

// Generates C header with:
// int32_t add(int32_t a, int32_t b);

Build as C library:

zig build-lib math.zig -dynamic
# Generates libmath.so (Linux), libmath.dylib (macOS)

C types

C Type Zig Type
int c_int
long c_long
char * [*c]u8
void * ?*anyopaque
size_t usize

Control flow

If expressions

const x = if (condition) 5 else 10;

// Multi-statement blocks
const y = if (condition) blk: {
    const temp = compute();
    break :blk temp * 2;
} else 0;

While loops

var i: u32 = 0;
while (i < 10) : (i += 1) {
    std.debug.print("{}\n", .{i});
}

// With optional continue expression
var sum: u32 = 0;
var i: u32 = 0;
while (i < 10) : (i += 1) {
    sum += i;
}

For loops

const items = [_]i32{ 1, 2, 3, 4, 5 };

// Iterate over array/slice
for (items) |item| {
    std.debug.print("{}\n", .{item});
}

// With index
for (items, 0..) |item, i| {
    std.debug.print("[{}] = {}\n", .{i, item});
}

Switch expressions

const value: i32 = 2;
const result = switch (value) {
    1 => "one",
    2 => "two",
    3, 4, 5 => "three to five",
    else => "other",
};

// ⚠️ Switch must be exhaustive or have else

Pointers

Pointer types

// Single-item pointer
var x: i32 = 5;
const ptr: *i32 = &x;
ptr.* = 10;                    // Dereference

// Many-item pointer (like T*)
const arr = [_]i32{ 1, 2, 3 };
const ptr: [*]const i32 = &arr;
const first = ptr[0];          // Index like array

// C pointer (nullable, may have unknown length)
const c_ptr: [*c]i32 = null;

// ⚠️ *T is never null, use ?*T for nullable

Pointer sizes

const std = @import("std");

// Pointer to unknown-size array
var arr = [_]i32{ 1, 2, 3, 4, 5 };
const ptr: [*]i32 = &arr;

// Slice (fat pointer: pointer + length)
const slice: []i32 = arr[0..];
std.debug.print("Len: {}\n", .{slice.len});

// ⚠️ [*]T has no length, []T has length

Alignment

const aligned_ptr: *align(16) i32 = ptr;

// Get alignment
const alignment = @alignOf(i32);  // Usually 4

// ⚠️ Over-aligned pointers require explicit casts

Gotchas

Integer overflow

var x: u8 = 255;
x += 1;                        // ⚠️ Runtime panic in Debug!

// Use wrapping arithmetic
x +%= 1;                       // Wraps to 0
x -%= 1;                       // Wraps to 255
x *%= 2;                       // Wrapping multiply

// Or saturating arithmetic
x +|= 1;                       // Saturates at 255

Array initialization

var arr: [100]i32 = undefined; // ⚠️ Uninitialized!
var zeros = [_]i32{0} ** 100;  // ✓ All zeros

// Can't do: arr = {0}; like C99

String concatenation

// ⚠️ NO string concatenation operator
// const str = "Hello " ++ "World"; // Compile error

// Use ArrayList or format
var list = std.ArrayList(u8).init(allocator);
try list.appendSlice("Hello ");
try list.appendSlice("World");

Const pointers

const ptr: *i32 = &x;          // ⚠️ Pointer is const, NOT pointee!
ptr.* = 42;                    // ✓ Allowed

const ptr: *const i32 = &x;    // ✓ Pointee is const
// ptr.* = 42;                 // Compile error

Type inference limits

// ⚠️ Must specify allocator result type
const list = std.ArrayList(i32).init(allocator); // ✓
// const list = std.ArrayList.init(allocator);   // Error: can't infer T

No function overloading

// ⚠️ Can't overload like C++
// fn add(a: i32, b: i32) i32 { ... }
// fn add(a: f64, b: f64) f64 { ... } // Error!

// Use generics or different names
fn addInt(a: i32, b: i32) i32 { ... }
fn addFloat(a: f64, b: f64) f64 { ... }

Defer order

var file1 = try openFile("a.txt");
defer file1.close();           // Runs second

var file2 = try openFile("b.txt");
defer file2.close();           // Runs first

// ⚠️ Defer runs in REVERSE order

Also see