Thoughts on Zig

#review#tech
Zig has the syntax of Rust
the runtime safety of Rust
the error handling of Go
the toolchain of Go
the module importing of JavaScript
the performance of C
the build system of Nix
what's not to like?

Lately I've been spending some free time playing around with the Zig programming language and it's now one of my favorite programming languages. It borrows the best features from all of my favorite languages. Because of this, I want to take some time to give my review of the Zig language - the good, the bad, and the ugly - in hopes that you try it out yourself.

The Good ¶

Language Design ¶

Zig's language design is absolutely phenomenal. First, the syntax is evidently well thought out. It's explicit while still remaining concise. Consider these two identical functions implemented in TypeScript and Zig respectively:

export function getData(num: string): number {
    const res = parseInt(num, 10);
    if (Number.isNaN(res)) throw new Error("Not a number!");
    return res;
}
const std = @import("std");
const DataError = error{
    NotANumber,
};

pub fn getData(num: []const u8) DataError!u8 {
    return std.fmt.parseInt(u8, num, 10) catch {
        return DataError.NotANumber;
    };
}

Although the Zig implementation may appear more verbose, everything is explicit. The TypeScript implementation doesn't tell us an exception can occur, and doesn't tell us what size (or sign) integer is returned. In Zig, errors are values that can be passed around and typed in function signatures.

Zig also supports using some blocks as expressions. For example, you can return the value of a conditional, a switch (which is exhaustive), and labeled blocks:

const num: u8 = if (other_num == 4) 3 else 5;

const x = switch (num) {
    1...10 => "small",
    11...50 => "medium",
    51...100 => "large",
    else => "unknown",
};

const total = blk: {
    var count: usize = 0;
    while (num < 42) : (num += 1) {
        count += 2;
    }
    break :blk count;
};

Zig has also done a great job reducing ambiguity. There are no function overloads or "hidden" parameters in Zig - everything must be explicitly stated.

// no hidden arguments or function overloads
std.log.info("Hello, world!", .{});

// no boolean coercion
if (x == 0) {
}

// mutability is explicit
const a = 4;
var b = &num;

// no operator overloads
const str = "Hello " ++ "world!";

All of these languages features make Zig code easy to read. You can browse and comprehend the standard library code without being a Zig expert.

Fine-Tuned Memory Management ¶

Rather than hide memory management like in Rust or Go, Zig instead emphasizes manual memory management by providing developers with the necessary tools (allocators, defer) to prevent leaks.

var client = std.http.Client{
    .allocator = std.heap.page_allocator,
};
defer client.deinit();

Zig also employs precise variable bit widths for structs, integers, and other types, making it incredibly efficient with bitfields or compile-time known data structures:

const three_byte_size: u3 = 7;

const two_byte_size = packed struct {
    half1: u8,
    quarter3: u4,
    quarter4: u4,
};

// enums can have variable width too!
const flag_type = enum(u2) {
    pending,
    is_alive,
    is_moving,
    is_healing,
};

// booleans are exactly 1 bit, not 1 byte
const is_programming = false;

Native C/C++ Interop ¶

Zig also has built-in C and C++ interoperability. This opens the door for Zig projects to include C libraries such as Ghostty writing a native GTK application in Zig. For example, here is the ncurses box example ported to Zig:

// run with `zig run -lncursesw -lc file.zig`
const c = @cImport({
    @cInclude("ncurses.h");
});

pub fn main() void {
    _ = c.initscr();

    _ = c.box(c.stdscr, '*', '*');
    _ = c.refresh();
    _ = c.getch();

    _ = c.endwin();
}

I've always wanted to use C libraries for small side projects but strongly dislike the C language. Zig is now the perfect substitute.

Built-in Inline Testing ¶

Zig also supports inline testing like Rust, meaning you don't have to export functions and data structures just to test them. Inline testing collocates code with tests, reducing cognitive overhead.

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

fn addNumbers(a: u8, b: u8) u16 {
    return a + b;
}

test "adds numbers correctly" {
    try expect(addNumbers(4, 4) == 8);
}

Comptime ¶

Probably the most interesting feature of Zig is its comptime feature, allowing developers to evaluate expressions and blocks at compile time if they choose. For example, running the test below catches the fact that we forgot to include a base case at compile time:

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

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

test "fibonacci" {
    try comptime expect(fibonacci(7) == 13);
}

One of the coolest uses of comptime I've seen is ensuring no errors exists in subsequent code at compile time:

fun myFn() !void {
    // some error prone logic

    errdefer comptime unreachable;

    // it is impossible for errors to occur beyond this point
}

Build System ¶

Zig supports a build system that is declarative in nature. It standardizes building other Zig projects using a common CLI interface so you don't have to install Meson, GNU Make, Ninja, Autoconf, etc. It just works.

const std = @import("std");

pub fn build(b: *std.Build) void {
    const name = "my_exe_name";
    const root_source_file = b.path("src/main.zig");
    const target = b.standardTargetOptions(.{});

    const exe_mod = b.createModule(.{
        .root_source_file = root_source_file,
        .target = target,
        .optimize = .ReleaseFast,
    });

    const exe = b.addExecutable(.{
        .name = name,
        .root_module = exe_mod,
    });
    b.installArtifact(exe);
}

The Bad ¶

Frequent Breaking Changes ¶

Because Zig is still in an alpha development state, it contains frequent breaking changes between releases. I can't in good conscience place blame on the language because I appreciate the effort to perfect the language, but I wish the breaking changes were announced and emphasized clearly. Zig should have a release notes RSS feed.

The Ugly ¶

Poor Documentation ¶

Unfortunately, Zig's Achilles heel is its documentation. The Zig documentation is simultaneously too verbose and not verbose enough. I had to read through nearly half the documentation just to understand the .{} tuple syntax from the "Hello world" example program. In many cases, the documentation simply doesn't exist. There's no description of what Server.receiveHead does or what Reader.stream does. Is this due to my lack of systems-level knowledge, or due to a lack of proper documentation?

There are also confusing redirects. For example, the GeneralPurposeAllocator link on std.heap redirects to the DebugAllocator. Does this mean using the general purpose allocator is discouraged, or is this a documentation bug?

In contrast, some documentation pages like std.log have fantastic documentation. There is an attempt at creating an external Zig guide at zig.guide but it hasn't been updated since 0.13.0. I wish the documentation was consistent.

Other Notes ¶

The only feature I haven't really worked with is Zig's dependency system. I don't completely understand how Zig's zon files work but maybe that's a good thing. I don't plan on using dependencies for the side projects I have in mind anyways.

Conclusion ¶

Zig is now one of my favorite programming languages and I love the direction it is headed. Although it has lacking documentation, I am optimistic in its bright future and happily anticipate Zig's first stable release. You can expect more Zig side projects from me in the future.