What Zig felt like, coming from Rust
Reflections on Transitioning from Rust to Zig
Introduction
Having spent the last seven years immersed in the Rust ecosystem—primarily contributing to open-source initiatives—I believe I've developed a strong intuition for the language. My personal preference leans toward the functional aspects of Rust: I value expressive type systems and the elegance of pure functions.
However, my curiosity regarding other languages led me to Zig. I've viewed it as a potential successor to C—a leaner, lower-level tool that is increasingly respected in the systems programming community. Given my early career experience with C, I felt a comparison between the two would be illuminating.
A Note on Perspective: My journey with Zig began specifically with the project described here. Consequently, some of my observations might seem elementary to seasoned Zig developers. My implementation choices were often guided by "Rust muscle memory" rather than native Zig idioms.
To ensure a balanced comparison, I decided to port a project I had already completed in Rust. I wanted something substantial enough to be useful, but not so massive that it became a sprawling odyssey. I chose JSONPath (as defined in RFC 9535). I transitioned the logic from my existing jsonpath-rust library to a new implementation: zig-jsonpath.
The IDE Shock
The most unexpected part of the transition wasn't the syntax or the memory model, but the near-total absence of IDE support. Coming from the luxury of RustRover and other JetBrains environments, I found Zig's tooling to be minimal—essentially just basic autocompletion and syntax highlighting.
While initially jarring, this forced me to return to the command line, which proved to be a refreshing experience. I rediscovered the efficiency of bare CLI tools. The centerpiece of this workflow was build.zig, which manages the project with impressive simplicity.
Common Build Commands
| Command | Purpose |
|---|---|
zig build test | Executes the full test suite |
zig build test -Dfilter = "..." | Runs a specific test function |
zig build test -Ddebug-query = true | Runs tests with debug logging enabled |
zig build compliance | Runs the RFC compliance suite |
zig build check | Combines unit tests and compliance checks |
This shift actually triggered a broader change in my development environment. I moved away from heavy IDEs in favor of a more modular stack:
- Helix (Editor)
- Alacritty (Terminal)
- Zellij (Multiplexer)
Embracing a Flat Structure
In Rust, I often agonize over the balance between file length and directory depth. The language encourages fragmenting code into deep hierarchies. While Zig allows this, it doesn't actively promote it—much like C.
I discovered that nesting files often introduces unnecessary import friction. I began asking myself: Does splitting this into five files actually improve readability?
I found that collapsing related logic into a single, larger file—and simply navigating by section—was often more efficient. If a model required a helper, I'd simply create a model_companion.zig file and keep it adjacent. While this might not scale to massive enterprise projects, the threshold for needing a complex hierarchy in Zig is much higher than I anticipated.
I used to think deep folders were mandatory for organization. Now, I realize I often organized out of habit rather than necessity.
Structural Comparison
The difference in philosophy is stark when looking at the source trees:
Testing Philosophies
If we set aside the RFC compliance suite and look at the language itself, my Rust testing strategy usually follows a simple formula:
In Zig, the approach is similar in that you can write tests directly within the source file. However, because of the flat structure I adopted, I faced a dilemma:
- Create a separate
model_test.zigfor every model (clutters the folder). - Inline the tests within the model file (clutters the code).
I ultimately chose the latter. By explicitly configuring the tests in build.zig, I was able to keep the process clean and efficient.
Overall, the process of writing and managing tests in Zig felt more streamlined once the initial configuration was handled.
// Example of a simple Zig test block
test "basic match function" {
const result = matchFunction("input", "pattern");
try std.testing.expect(result == true);
}