Why Zig?

Last year I learned Zig for Advent of Code as part of an ongoing tradition to solve the puzzles in a different programming language each year. Sometimes I pick a language I'm relatively comfortable with (Clojure, Go) because I don't get a chance to work with it as often as I would like. Other years, I pick a language that I have little/no experience with (Zig, Nim, Julia, Rust), to see what it's all about.

I don't think Zig is all that great for solving these kinds of puzzles, but what impressed me is how expressive and elegant it can be, considering its simple low level programming model.

I actually enjoyed writing Zig so much for the daily puzzles that I started some Zig side projects, as an excuse to dive deeper into the language.

Here's what I think Zig gets right, in no particular order.

Testing

Zig belongs to the sensible class of languages that recognise testing as part of the language, not the ecosystem. It has dedicated syntax and built-in tools for testing.

Running zig test will run all tests that specified in your build.zig or you can run zig test file.zig to test a specific file.

Running those tests involves executing test blocks in the appropriate source files.

const std = @import("std");
const testing = std.testing;

test "examples" {
    try testing.expectEqual(2, 1 + 1);
}

The testing namespace in the standard library contains assertions for comparing all sorts of values, pointers, optionals, and errors.

It also contains a custom allocator that will report memory leaks. This is actually a big deal, as Zig's manual memory management means every single allocation and free is explicit. std.testing.allocator makes sure that you don't forget to free memory, which eliminates a significant class of bugs.

You can (and usually should) write these tests inside your source files. They become an invaluable source of compiler-checked documentation and examples.

I have a bit of a gripe with the argument ordering of the assertion functions. Here's the definition of expectEqual straight out of the source for Zig v0.11.0:

pub fn expectEqual(expected: anytype, actual: @TypeOf(expected)) !void {

The expected value comes second, which can make writing tests feel like "Yoda conditions". There are reasons but I often forget to swap the arguments and I'm now used to reading the output backwards.

There isn't a watch mode with zig test so I configure it as my default test task in VSCode, which I bind to a keyboard shortcut, making it easy to run my tests from anywhere in the project.

Control Flow

In many programming languages there's an apparent control flow. Execution moves sequentially through statements, iteratively through loops, and jumps away when calling functions. In most programming languages there's also a hidden control flow. Exceptions can halt execution when something further up the stack throws. Syntatic overloads can result in calls out to user defined routines. The worst offenders are macros, because there's no telling what might happen when you call those.

That hidden control flow? Zig doesn't have any of it. Function calls are the only time that you have to think about the consequences of ending up in a new stack frame. This allows you to rule out a whole class of possibilities when you are debugging. It forces you to design more explicit interfaces and to rely less on language level magic that might not be obvious to less experienced programmers.

Error Handling

Rather than having dedicated control flow for exceptions, Zig uses first class error return values. The important distinction is that Zig's compiler recognises error unions as a separate kind of value from regular values or unions.

Let's look at Go (another language which uses errors-as-values) to understand the difference.

func random() (float64, error) {
    // ...
}

This random function returns two values and the second one happens to be an error. There's nothing to stop you from swapping the return signature, and error-last is a convention. The compiler doesn't recognise a semantic difference from returning two floats, so it can't help enforce the pattern.

It's up to the caller to recognise the error value and to handle it accordingly.

val, err := random();

if err != nil {
  // ...
}

This pattern is brilliantly simple, but it adds 3+ lines of code every time you call a function. This contributes to a general level of noise in some functions. Here's an example from one of my own Go codebases.

Here's how the same signature would look in Zig.

fn random() !f64 {
    // ...
}

The ! in the signature tells us that the function can return error value or a floating point value. There's no convention here, if your function can fail, the error must be encoded into the return type.

Callers don't have to worry about unpacking multiple values and checking them individually. Instead they receive a single value that can be an error or a value. This might not seem impressive if you've used languages with union types but it starts to shine with control flow primitives that are explicitly designed to work with error unions.

if statements have capturing behaviour that can unwrap error unions.

if (random()) |val| {
  // val is an f64
} else |err| {
  // err is an error
}

catch statements give you a chance to provide an default value without a full control flow branch.

const number = random() catch 42;

And try is a shortcut for returning the error from inside a catch branch.

try random();
// is equivalent to writing
// random() catch |err| return err;

Propagating errors in Zig adds minimal noise considering it's manual and explicit. There are no exceptions or recoverable panics.

Because the compiler recognises errors, it can manage their stack traces as they make their way back down the stack. The error remembers where it came from without needing to be manually wrapped along the way.

When error handling is too explicit in a programming language, it becomes tedious and people look for ways to avoid or abstract it. When error handling is too implicit then people forget to do it.

Zig strikes a great blend between explicit errors and pragmatic simplicity, with the best model for error handling and optional values that I've ever used in a programming language.

Standard Library

To talk about the standard library properly, we have to talk about generics. Fortunately, Zig's generics are one of the most interesting aspects of the language.

In other languages you might parameterise types and function calls with a dedicated syntax. For example ArrayList<number> might define an array of numbers. In Zig, this would be ArrayList(f64).

This isn't a special syntax exception. This is a function call. Types can be passed around like values.

You see this pattern throughout the standard library. For example, the std.mem namespace contains lots of functions that operate on arrays, but because Zig accepts types as parameters, there is no need for function overloading.

Zig offers a solid set of generic data structures including hash maps, array lists, sets/bitsets, priority queues, tail queues, and bloom filters. For a low level language, this is impressive, and goes a long way towards the front page claim that "Zig is a general-purpose programming language".

For application level programming, Zig still falls a long way short of a language like Go, which provides much higher level interfaces for working with HTTP, SQL, regular expressions, templating languages, image processing, and more.

The documentation for the standard library isn't good. There's a prominent banner which acknowledges this and suggests that you read the standard library's source code instead. This will be a dealbreaker for some people, and fair enough. If you decide to persevere then you'll find that the standard library is an idiomatic codebase, clearly written, well commented, and full of inline tests that serve as examples.

Any editor with support for language server protocols should make it a one step process to jump to the definition of any type or function from the standard library, and hopping backwards and forwards between my own code and library code has been an invaluable part of learning the language.

Containers

Syntactic blocks like structs can hold their own variable and function declarations. This seemed a little arbitrary to me at first. Here's a struct that has two properties:

const Vec = struct {
    x: f64,
    y: f64,
};

It becomes more interesting when we introduce declarations alongside those properties.

const Vec = struct {
    x: f64,
    y: f64,

    fn of(x: f64, y: f64) Vec {
        return .{ .x = x, .y = y };
    }

    fn size(v: Vec) Vec {
      return std.math.hypot(f64, v.x, v.y);
    }
};

We can refer to these functions as static member of Vec.

const v = Vec.of(1, 2);

And we can refer to them as members on instances of Vec (so long as the types line up)!

const size = Vec.of(1, 2).size();

This is probably the most sensible implementation of static members and methods that I remember seeing. It not limited to structs either. Enums, unions, and opaques are also containers.

Here's a real example from Advent of Code.

const Sign = enum {
    rock,
    paper,
    scissors,

    fn parse(ch: u8) Sign {
        return switch (ch) {
            'A', 'X' => .rock,
            'B', 'Y' => .paper,
            'C', 'Z' => .scissors,
            else => unreachable,
        };
    }

    fn score(sign: Sign) u32 {
        return switch (sign) {
            .rock => 1,
            .paper => 2,
            .scissors => 3,
        };
    }
}

If Zig didn't support containers, then these complementary functions would probably need some manual namespacing (for example parseSign and scoreSign) to prevent collisions with functions from other types in the same scope.

Everything Else

This wasn't supposed to be a deep dive on Zig, so here are some quick overviews on other things that I appreciate about the language.

Rough Edges

Zig isn't a mature or stable programming language yet (sometimes it seems like everyone is working off of Zig's master branch) even if it's heading in the right direction. Here are some of my pain points.

When?

I don't write low level programs enough to remember all the footguns of a language like C, or all the complexities of a language like Rust. Zig fits comfortably in the middle, with semantics that are easy to approach and easy to remember.

I think Zig is a great language for people who are interested in exploring lower level programming without a steep barrier to entry. This will be especially true as the language continues to smooth out the rough edges mentioned above.