Zig Study Projects

Three small projects pull the whole track together: a pointer-driven linked list, a comptime generic stack with tests, and a command-line journal that uses files, arguments, and an allocator. Type each one yourself, then break it on purpose.

Singly Linked List

The linked list is the classic exercise for pointers and optionals together: nodes are allocated on the heap, the next pointer is a ?*Node, and walking the list is a while loop with an optional capture.

Node and Operations

const std = @import("std");

const Node = struct {
    value: u32,
    next: ?*Node = null,
};

fn prepend(list: *?*Node, allocator: std.mem.Allocator, value: u32) !void {
    const node = try allocator.create(Node); // allocate one Node
    node.* = .{ .value = value, .next = list.* };
    list.* = node;
}

test "prepend three nodes" {
    var head: ?*Node = null;
    try prepend(&head, std.testing.allocator, 3);
    try prepend(&head, std.testing.allocator, 2);
    try prepend(&head, std.testing.allocator, 1);
    try std.testing.expect(head.?.value == 1);
}

Iteration with Optionals

Freeing the list is the mirror image: walk it, save the next pointer before freeing, and use errdefer-style discipline so no path leaks. The optional while loop makes the boundary conditions explicit.

fn destroy(list: *?*Node, allocator: std.mem.Allocator) void {
    var cur = list.*;
    while (cur) |node| {
        const next = node.next; // remember before freeing
        allocator.destroy(node);
        cur = next;
    }
    list.* = null;
}

Generic Stack

A stack over any element type is the canonical comptime container: the type parameter flows into an internal array, and every instantiation is monomorphized and type-safe.

comptime Implementation

fn Stack(comptime T: type, comptime capacity: usize) type {
    return struct {
        items: [capacity]T = undefined,
        len: usize = 0,

        fn push(self: *@This(), value: T) error{StackFull}!void {
            if (self.len == capacity) return error.StackFull;
            self.items[self.len] = value;
            self.len += 1;
        }
        fn pop(self: *@This()) ?T {
            if (self.len == 0) return null;
            self.len -= 1;
            return self.items[self.len];
        }
    };
}

Unit Tests

test "stack push and pop" {
    var s = Stack(u32, 4){};
    try s.push(10);
    try s.push(20);
    try std.testing.expectEqual(@as(u32, 20), s.pop().?);
    try std.testing.expectEqual(@as(u32, 10), s.pop().?);
    // pop empty: returns null — absence is encoded in the type
    try std.testing.expect(s.pop() == null);
}

CLI Journal Tool

A real command-line program touches nearly every lesson: arguments, allocators, formatting, errors, and file I/O. This journal appends a line to a file and reads the whole journal back.

Appending Entries

const std = @import("std");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    const args = try std.process.argsAlloc(allocator);
    defer std.process.argsFree(allocator, args);

    if (args.len < 2) {
        std.debug.print("usage: journal <entry>\n", .{});
        return error.InvalidArguments;
    }

    var file = try std.fs.cwd().createFile("journal.txt", .{ .truncate = false });
    defer file.close();
    try file.seekFromEnd(0);       // append at the end
    try file.writeAll(args[1]);
    try file.writeAll("\n");
    std.debug.print("entry saved.\n", .{});
}

Reading the Journal

Reading a whole file is one allocator call and one free — no manual buffer plumbing, and the leak detector stands guard.

const log = try std.fs.cwd().readFileAlloc(allocator, "journal.txt", 1 << 20);
defer allocator.free(log);
std.debug.print("journal:\n{s}", .{log});
Next challenge: extend the journal with a search flag that filters lines by a substring, then test it with zig test. Every skill this track teaches — types, errors, allocators, string slicing — appears in that one feature.