December always brings me two things: Last Christmas on repeat, courtesy of my wife, and Advent of Code programming puzzles. While I'm not so enthusiastic about the music, I enjoy helping the Elves save Christmas, one puzzle at a time.

The Advent of Code puzzles can be solved in any language as puzzles are just instructions and input data, making it perfect for trying out new languages or learning more a language I already know. This year, I tried out Zig, a language I've been curious for a while. . Especially after adopting Ghostty as my default terminal emulator. Ghostty is a good example of how Zig can be a good option for writing systems' software, despite being a relatively young language.

This post is by no means a comprehensive review of Zig, as I have not touched parts where the language shines such as the build system and compile-time execution. My goal was just to break the ice with the language and share my impressions on using it to solve Advent of Code problems.

Setting up the environment

Installing Zig is rather similar, in my opinion, to installing Golang: download a precompiled binary for your platform, extract it somewhere in your system, and add it to your PATH. I installed the current stable version, 0.15.2.

I like that Zig comes with a command to create new projects:

mkdir my-project
cd my-project
zig init

This command creates an example project structure with a src folder, a build.zig file. The src folder contains the example of a main and also how to write tests. Test definitions are as simple as creating a code block starting with the test keyword and a string description.

test "example test" {
}

I have also found interesting that tests can live in the same file as the code. This is similar to how Rust allows you to write tests in the same file as the code being tested. I usually separate tests from the code, but, perhaps for small modules, this could be a good approach.

The Advent of Code boilerplate and a good first look at Zig features

After learning a bit about the project structure, I created some boilerplate code to read input files and execute my solutions and get familiar with file I/O in Zig. It was also my first encounter with confusing documentation websites and API changes.

For example, openFile is under std.fs.Dir in Zig 0.15.2, but for the most recent version, 0.16.0, it has been moved to std.Io.Dir. This is in no way a criticism of Zig itself, but rather a reflection the language is still evolving rapidly and upgrading to newer versions may require lots of adjustments in the code.

After some trial and error, I came up with this:

const std = @import("std");

pub fn readFile(path: []const u8) ![]u8 {
    var file = try std.fs.cwd().openFile(path, .{ .mode = .read_only });
    defer file.close();
    const contents = try file.readToEndAlloc(std.heap.page_allocator, 32768);
    return contents;
}

That function does the job, but as I write this, the docs say that readToEndAlloc is already deprecated.

Able to read a file, I created the main program to read the day and the year from command line arguments and call the corresponding solution function. Each solution lives on its own file. The main program imports them and selects the right one to execute based on the day and year arguments. Implementing that logic introduced me to Zig's approach for error handling, based on try and catch keywords. And I can say: I really like it!

A function that can fail is marked with a ! before the return type. When calling such a function, we can use try to propagate the error up the call stack, or catch to handle it locally. This makes error handling explicit and clear. For example:

fn parse_args() !Date {
    const allocator = std.heap.page_allocator;
    var args = try std.process.argsWithAllocator(allocator);
    ...
}       

We can then catch errors as follows:

  const date = parse_args() catch |err| {
        switch (err) {
            error.MissingYear => std.debug.print("Missing year argument. e.g, 2025 \n", .{}),
            error.MissingDay  => std.debug.print("Missing day argument. e.g, 01 \n", .{}),
        }
        return;
    };

The switch statement is to me the cherry on the top. It makes it easy to handle different error cases in a clean and organized way, similarly to Rust's match statement.

Another cool feature is the defer statement. It's similar to Go's defer, and it allows scheduling a function call to be executed when the current scope exits. This is useful for cleaning up resources, such as closing files or freeing memory. In the readFile function above, I used defer file.close(); to ensure that the file is closed when the function exits, regardless of whether it exits normally or due to an error.

Final thoughts

Besides the documentation and unstable ABI, Zig is showing a lot of promise, with lots of nice features from modern languages, but with its own approach for error handling and resource management.

With the boilerplate code ready and my hands finally wet, I was set to solve the first puzzle. But that is a story for the next post!