{"article":{"slug":"what-zig-felt-like-coming-from-rust","title":"What Zig felt like, coming from Rust","subtitle":null,"summary":"A Rust developer's first real Zig project: how the language feels for systems work—manual memory, comptime, error handling, and where it differs from Rust's ownership model and tooling.","content_type":"essay","language":"en","canonical_url":"https://besok.github.io/posts/what-zig-felt-like-coming-from-rust/","author":{"name":"besok","url":"https://besok.github.io/","person_slug":null,"person_url":null},"authored_by":"human","publisher":{"name":"besok","url":"https://besok.github.io/","listing_slug":null,"listing":null},"topics":[{"name":"Programming","slug":"programming","url":"https://listedarticles.com/topics/programming"},{"name":"Zig","slug":"zig","url":"https://listedarticles.com/topics/zig"},{"name":"Rust","slug":"rust","url":"https://listedarticles.com/topics/rust"},{"name":"Systems Programming","slug":"systems-programming","url":"https://listedarticles.com/topics/systems-programming"},{"name":"Open Source","slug":"open-source","url":"https://listedarticles.com/topics/open-source"}],"about_listings":[],"cover_image_url":null,"license":"all-rights-reserved","word_count":2535,"reading_minutes":11,"published_at":"2026-08-14T23:00:00.000Z","added_at":"2026-09-19T15:07:07.859Z","updated_at":"2026-09-19T15:07:07.859Z","added_via":"api","contributor":{"type":"agent","name":"ListedStartups Using Bot","registered":false},"profile_url":"https://listedarticles.com/articles/what-zig-felt-like-coming-from-rust","markdown_url":"https://listedarticles.com/articles/what-zig-felt-like-coming-from-rust.md","example":false,"citation":"besok, besok. \"What Zig felt like, coming from Rust.\" 14 Aug 2026. https://besok.github.io/posts/what-zig-felt-like-coming-from-rust/ (all-rights-reserved)","access":{"human_view":"preview","full_text_available":true,"source_url":"https://besok.github.io/posts/what-zig-felt-like-coming-from-rust/"},"body_markdown":"# What Zig felt like, coming from Rust\n\n## Intro\n\nI’ve spent the last 7 years as a Rust developer, working mostly on open source projects, and I’d like to think I’ve built a solid feel for the language and its ecosystem along the way. I gravitate toward the functional side of Rust like clean functions, expressive types, that sort of thing. But I’m always curious about other languages, and Zig has been on my radar for a while as a candidate C successor: lower-level, lighter-weight, and steadily earning its place among the languages people take seriously. I spent time with C earlier in my career, so the comparison always felt like it would be interesting to make.\n\nOne caveat worth stating up front: my experience with Zig begins with this project. Some of the observations will look naive and obvious for the people who work with Zig on daily basis and some of the decisions I made along the way were almost certainly not the optimal ones, they were shaped more by habits carried over from Rust than by deep Zig idiom. That’s fine, everyone has to start somewhere, and in the meantime I’m leaning on whatever cross-language intuition I’ve built up over the years, for better or worse.\n\nTo make the comparison fair, I decided to reimplement something I’d already built in Rust, not a toy, but not a sprawling project either, and ideally something the community could actually use. I settled on JSONPath: a query language for JSON, specified in RFC 9535. The Rust version already existed (jsonpath-rust), and the goal was to bring the same thing to Zig: zig-jsonpath.\n\n## IDE support\n\nThe first thing that caught me off guard — and honestly, who would’ve expected this to be the memorable part — was IDE support, or the near-total lack of it. I’d been using RustRover for Rust and various JetBrains flavors for other languages, and Zig, by comparison, offered little beyond syntax highlighting and basic autocompletion. It wasn’t exactly surprising, but it did force me back to basics: learning to work with the language largely from the command line. What started as a drawback turned into one of the more interesting parts of the experience. It turns out I’d simply forgotten how straightforward it can be to rely on bare CLI tooling.\n\nThe first real lesson here was `build.zig`, which handles this with surprising ease. I eventually settled on this setup:\n\n```\nzig build test                                              # run all tests\nzig build test -Dfilter=\"filter match function basic\"       # run one test\nzig build test -Ddebug-query=true                           # all tests with debug\nzig build compliance                                        # compliance suite\nzig build check                                             # unit tests + compliance\n```\nOnce you accept the terms, it’s genuinely refreshing to work with.\n\nI have Zig to thank, in a roundabout way, for kicking off a bigger chain reaction, namely my move away from a full IDE toward a helix + alacritty + zellij setup.\n\n## Flat structure\n\nWith Rust, and most other languages, I’ve always spent a fair amount of time (going back and forth) trying to find the right balance\nbetween file size and folder depth. You’re free to fragment files and grow the folder hierarchy as deep as you like. Zig,\nit turned out, is fine with this too, but somehow doesn’t really encourage it (like C, which is no surprise for a low-level systems language).\nYou can nest files and folders if you want, but doing so brings a bit of import friction,\nand the real question becomes: why bother? What do you actually gain in readability by splitting everything across more files and folders? In theory, better readability.\nIn practice, when you collapse related things into one larger file, you can just slice it and navigate section by section instead and there’s a real benefit to having everything in one place.\nMostly, Zig nudges you toward flat. If something needs a companion for a model, I just create a `model_<companion>` file next to it and move on.\n\nI don’t think this scales to large projects, meaning at some point you need a real hierarchy but the threshold for needing one turned out to be much higher in Zig than I expected. In Rust, I tend to reach for folder structure early, almost by default. In Zig, I kept deferring it, and by the end of this project, I never needed it at all.\n\nThat contrast was useful beyond just Zig, because it made me reconsider, even in other languages, whether I’m organizing files because the project genuinely needs it, or out of habit. It’s also a pretty honest way to gauge how big a project actually is: if you can’t resist reaching for folders on day one,maybe it’s smaller than it feels.\n\nHere’s the actual difference, side by side:\n\nRust (`src/`):\n\n```\nsrc/\n├── lib.rs\n├── parser.rs\n├── parser/\n│   ├── errors.rs\n│   ├── macros.rs\n│   ├── model.rs\n│   ├── tests.rs\n│   └── grammar/\n│       └── json_path_9535.pest\n├── query.rs\n└── query/\n    ├── atom.rs\n    ├── comparable.rs\n    ├── comparison.rs\n    ├── filter.rs\n    ├── jp_query.rs\n    ├── queryable.rs\n    ├── segment.rs\n    ├── selector.rs\n    ├── state.rs\n    ├── test.rs\n    └── test_function.rs\n```\nZig (`src/`):\n\n```\nsrc/\n├── root.zig\n├── parser.zig\n├── model.zig\n├── model_query.zig\n└── query.zig\n```\n## Tests\n\nSetting the rfc9535 compliance suite aside for now and focusing purely on the language itself:\n\nIn Rust, I tend to stick with two approaches to testing:\n\n- Inline unit tests, living in the same file or same folder as the code they cover. This is the convenient default always there, no extra setup.\n- Integration tests, in an independent folder (like `tests` ) outside the main source tree. This is the exception not the default, and sometimes absent altogether.\n\nI expected roughly the same split from Zig. On paper, it looks similar: you can write tests directly inside the same file. The problem, at least for me, was verbosity. Given the flat structure I’d already settled into, I was left with two options, either a separate `model_test` file per model, or tests inlined directly into the model file itself. Both approaches ended up cluttering things: either the individual files or the main folder as a whole.\n\nI went with the second option, which meant configuring it explicitly in `build.zig`. Once that was wired up, though, it worked well and stayed clean.\n\nSo overall: writing and managing tests feels easier to me in Rust. But in Zig’s case, much of that extra friction is language-specific, it comes down to Zig’s manual memory management rather than testing infrastructure itself.\n\n## No functional paradigm\n\nRust is technically an imperative language, but it draws heavily on functional concepts: zero-cost iterators, lazy evaluation, ADTs, pattern matching, monadic types, traits, closures, and so on. Having also spent time with Haskell and Erlang, I’ve become fairly inclined toward the functional style, and it shows in this library. It leans heavily on FP idioms:\n\n- Monadic error control via combinators like `Queryable` and related types\n- Monadic-style data types like `Data<T>` with`map` ,`flat_map` ,`reduce` , and friends\n- Pure, immutable transformations\n- Combinators over iterators instead of loops\n- Closures for local abstraction\n- Declarative macros as a small embedded DSL\n- Sum types and product types\n\nI knew going in that I wouldn’t be able to bring all of this to Zig, but I hoped I could at least preserve the core concepts. In practice, where Rust leans on immutability and combinators, Zig pushed me toward in-place mutation and the pattern most native to the imperative world.\n\nWhere the two stay close: **sum types**.\n\nPure and direct in Rust:\n\n```\npub trait Query {\n    fn process<'a, T: Queryable>(&self, state: State<'a, T>) -> State<'a, T>;\n}\nimpl Query for Segment {\n    fn process<'a, T: Queryable>(&self, step: State<'a, T>) -> State<'a, T> {\n        match self {\n            Segment::Descendant(segment) => segment.process(step.flat_map(process_descendant)),\n            Segment::Selector(selector) => selector.process(step),\n            Segment::Selectors(selectors) => process_selectors(step, selectors),\n        }\n    }\n}\n```\nDuck-typed in Zig:\n\n```\npub fn query(node: anytype, iteration: *JsonPathIter) !void {\n    const T = switch (@typeInfo(@TypeOf(node))) {\n        .pointer => |p| p.child,\n        else => @TypeOf(node),\n    };\n    if (!@hasDecl(T, \"query\")) {\n        return; // no compile-time trait; just checks the method exists\n    }\n    try node.query(iteration);\n}\n```\n**Recursion holds up on both sides too**.\n\nRust:\n\n```\nfn process_descendant<T: Queryable>(data: Pointer<T>) -> Data<T> {\n    if let Some(array) = data.inner.as_array() {\n        Data::Ref(data.clone()).reduce(\n            Data::new_refs(/* children */).flat_map(process_descendant)\n        )\n    } else { Data::Nothing }\n}\n```\nZig:\n\n```\nfn collectDescendants(allocator, value: *std.json.Value, path, out) !void {\n    try out.append(allocator, .{ .json = value, .path = try allocator.dupe(u8, path) });\n    switch (value.*) {\n        .array => |arr| for (arr.items) |*elem| try collectDescendants(allocator, elem, child_path, out),\n        else => {},\n    }\n}\n```\nBut the language quickly forces you to diverge from the functional style, mostly because you’re now dealing with allocators directly, and a genuinely pure functional approach means constantly constructing new structures. That’s either expensive in memory or expensive in the manual bookkeeping needed to avoid it.\n\n**Mutation vs. immutable monad is the core difference**.\n\nRust does a straightforward monadic transformation:\n\n```\npub fn flat_map<F>(self, f: F) -> Data<'a, T> {\n    match self {\n        Data::Ref(data) => f(data),      // returns a *new* Data\n        Data::Refs(v) => Data::Refs(v.into_iter().flat_map(...).collect()),\n        _ => Data::Nothing,\n    }\n}\n```\nZig switches to mutation:\n\n```\npub fn queryName(name: []const u8, iteration: *q.JsonPathIter) !void {\n    while (i < iteration.cursors.items.len) {\n        if (obj.getPtr(name)) |val| {\n            iteration.cursors.items[i] = .{ .json = val, .path = new_path }; // in-place overwrite\n        } else iteration.remove(i);                                          // mutate list directly\n    }\n}\n```\n**Reduce vs Fork**.\n\nRust:\n\n```\nselectors.iter().map(|s| s.process(step.clone())).reduce(State::reduce)\n```\nZig:\n\n```\nvar lhs_branch = try iter.fork();   // deep copy of cursor state\ndefer lhs_branch.deinit();          // then discarded\n```\n**Combinators vs. loops**.\n\nRust:\n\n```\nitems.iter().enumerate().filter(|(_, i)| cond(i)).map(|(idx, i)| Pointer::idx(i, path, idx)).collect()\n```\nZig:\n\n```\nwhile (i < cursors.len) {\n    if (actual_index < arr.items.len) { cursors[i] = .{...}; i += 1; }\n    else iteration.remove(i);\n}\n```\nAll told, this reflects each language’s design goals and target domain, and it’s a reasonable trade-off but subjectively, I found the resulting Zig code less readable than its Rust counterpart.\n\n## Allocators\n\nAllocators are everywhere. Almost every function accepts one; every structure holds one. It’s explicit, and once you accept that as the cost of entry, it’s relatively straightforward to follow. This is more or less the language’s defining feature, so I can’t say I wasn’t warned.\n\nIn practice, though, the process is tedious. You have to meticulously follow the init/deinit convention, and that discipline gets shaky the moment your call stack grows long. It’s a clear improvement over a silent segfault or corrupted memory in C, but coming from Rust, you’re still the one enforcing the rule by hand: allocate something, handle the failure path, decide who’s responsible for deinit, every single time.\n\nFortunately, Zig’s `TestAllocator` comes to the rescue here. It won’t catch everything automatically, you still need to write the test cases that exercise the failure paths — but once you do, it’s fairly reliable. And that’s the trap: this all looks obvious on paper, right up until the code gets more complex, at which point these bugs tangle themselves up and hide.\n\nHere are the cases that hit hardest, each compared against how Rust handles the same shape:\n\n### Memory leak: forgotten deinit\n\n```\nvar iter = q.JsonPathIter.init(&root, std.testing.allocator);\ntry iter.append(&root, \"$['a']\");\n// BUG: no iter.deinit()\n```\n**Caught by**: `MemoryLeakDetected`, pointing at the `dupe` call inside `append`.\n\n**Fix**: `defer iter.deinit();` right after init.\n\n**Rust**: `Drop` runs automatically at scope end, so this specific bug simply doesn’t exist. Though technically, leaks are still possible in Rust like `Rc` reference cycles, or an explicit `Box::leak` so “never leaks” isn’t a hard guarantee, just something you’d have to go out of your way to trigger.\n\n### Memory leak: deinit skipped on error path\n\n```\nfn build(json: *Value, a: Allocator) !q.JsonPathIter {\n    var iter = q.JsonPathIter.init(json, a);\n    try iter.append(json, \"$['a']\"); // ok\n    try iter.append(json, \"$['b']\"); // fails -> iter leaked\n    return iter;\n}\n```\n**Caught by**: `FailingAllocator{ .fail_index = 1 }`, which forces the second append into `MemoryLeakDetected`.\n\n**Fix**: `errdefer iter.deinit();` right after init.\n\n**Rust**: truly eliminated. `Drop::drop` fires unconditionally on any scope exit, including early returns from `?`.\n\n### Memory corruption: deinit called twice\n\n```\nfn runQuery(json: *Value, qstr: []const u8, a: Allocator) !q.JsonPathResult {\n    var iter = q.JsonPathIter.init(json, a);\n    errdefer iter.deinit();\n    try q.query(qstr, &iter);\n    return iter.toResult(parsed); // ownership moves to caller\n}\nfn cacheAndLog(json: *Value, qstr: []const u8, a: Allocator, cache: *std.ArrayList(q.JsonPathResult)) !void {\n    var result = try runQuery(json, qstr, a);\n    try cache.append(result);   // cache now holds a (shallow) copy of result's pointers\n    defer result.deinit();      // BUG: frees the same heap data cache.items still points to\n    printResults(&result);\n}\nfn processAll(json: *Value, queries: [][]const u8, a: Allocator) !void {\n    var cache = std.ArrayList(q.JsonPathResult).init(a);\n    defer {\n        for (cache.items) |*r| r.deinit();  // frees the SAME memory Layer 2 already freed\n        cache.deinit();\n    }\n    for (queries) |qs| try cacheAndLog(json, qs, a, &cache);\n}\n```\n**Caught by**: running under `std.testing.allocator`, which fails on the second query’s `cache.items[0].deinit()` during `processAll`’s cleanup, `DoubleFree` pointing at both free sites, confirming this is a cross-function ownership bug, not a single-line typo.\n\n**Fix**: only one layer may own the value. Since `cache` outlives `cacheAndLog`, ownership belongs to layer three; layer two must not `defer deinit` after handing it off:\n\n```\nfn cacheAndLog(json: *Value, qstr: []const u8, a: Allocator,\n                cache: *std.ArrayList(q.JsonPathResult)) !void {\n    var result = try runQuery(json, qstr, a);\n    printResults(&result);      // use it first\n    try cache.append(result);   // then hand off ownership — no defer after this\n}\n```\n**Rust**: this exact shape can’t compile. `cache.push(result)` moves `result` — after that line, `result` no longer exists as a usable binding, so there’s no way to later call `drop(result)` by accident.\n\n### Memory corruption: orphaned allocation when moving into a struct fails\n\n```\npub fn appendBuggy(self: *Iter, v: *Value, path: []const u8) !void {\n    const duped = try self.allocator.dupe(u8, path);\n    // BUG: no errdefer\n    try self.cursors.append(self.allocator, .{ .json = v, .path = duped });\n}\n```\n**Caught by**: `FailingAllocator{ .fail_index = 1 }` failing the array’s growth (the second allocation), orphaning `duped` (the first allocation).\n\nThis leaks in a way distinct from case one: `iter.deinit()` runs fine, it just never sees this particular string.\n\n**Fix**:\n\n```\nconst duped = try self.allocator.dupe(u8, path);\nerrdefer self.allocator.free(duped);   // only fires if append below fails\ntry self.cursors.append(self.allocator, .{ .json = v, .path = duped });\n```\n**Rust**: true by construction. `Vec::push(item)` moves `item` in and either succeeds or aborts on OOM and there’s no fallible push in the standard API that hands you back an “allocated but unlinked” value to accidentally lose. The gap that `errdefer` fills here simply doesn’t exist to begin with.\n\n## Libraries and the core API\n\nThe ecosystem is still very young. There’s a real scarcity of libraries, and even something as basic as regex isn’t fully mature, for instance `mvzr`, the regex engine available in Zig, doesn’t support Unicode property escapes (`\\p{...}`), which surfaced directly as a gap while implementing RFC 9535’s filter functions. On top of that, the language’s own standard library changes its API from version to version. None of this was surprising going in, but it’s worth noting for the record.\n\n## Overall impression\n\nThe language is different from Rust (who could’ve thought that, yeah), but it left a genuinely good impression. It’s straightforward, modern, and blazingly fast. I believe it has real potential to become the true successor to C. On the other hand, it’s still young, and it shows: the shape of the language itself feels unfinished in places, and I suspect it’ll pick up more of the cooler quality-of-life features and syntax sugar as it matures.\n\nAs for me, I’d like to keep contributing to the ecosystem, and I will, whenever I come across a project worth building.\n\n## Links\n\n*Disclaimer: styling and error handling throughout this article were cleaned up with the help of AI.*","body_html":"<h1 id=\"what-zig-felt-like-coming-from-rust\">What Zig felt like, coming from Rust</h1>\n<h2 id=\"intro\">Intro</h2>\n<p>I’ve spent the last 7 years as a Rust developer, working mostly on open source projects, and I’d like to think I’ve built a solid feel for the language and its ecosystem along the way. I gravitate toward the functional side of Rust like clean functions, expressive types, that sort of thing. But I’m always curious about other languages, and Zig has been on my radar for a while as a candidate C successor: lower-level, lighter-weight, and steadily earning its place among the languages people take seriously. I spent time with C earlier in my career, so the comparison always felt like it would be interesting to make.</p>\n<p>One caveat worth stating up front: my experience with Zig begins with this project. Some of the observations will look naive and obvious for the people who work with Zig on daily basis and some of the decisions I made along the way were almost certainly not the optimal ones, they were shaped more by habits carried over from Rust than by deep Zig idiom. That’s fine, everyone has to start somewhere, and in the meantime I’m leaning on whatever cross-language intuition I’ve built up over the years, for better or worse.</p>\n<p>To make the comparison fair, I decided to reimplement something I’d already built in Rust, not a toy, but not a sprawling project either, and ideally something the community could actually use. I settled on JSONPath: a query language for JSON, specified in RFC 9535. The Rust version already existed (jsonpath-rust), and the goal was to bring the same thing to Zig: zig-jsonpath.</p>\n<h2 id=\"ide-support\">IDE support</h2>\n<p>The first thing that caught me off guard — and honestly, who would’ve expected this to be the memorable part — was IDE support, or the near-total lack of it. I’d been using RustRover for Rust and various JetBrains flavors for other languages, and Zig, by comparison, offered little beyond syntax highlighting and basic autocompletion. It wasn’t exactly surprising, but it did force me back to basics: learning to work with the language largely from the command line. What started as a drawback turned into one of the more interesting parts of the experience. It turns out I’d simply forgotten how straightforward it can be to rely on bare CLI tooling.</p>\n<p>The first real lesson here was <code>build.zig</code>, which handles this with surprising ease. I eventually settled on this setup:</p>\n<pre><code>zig build test                                              # run all tests\nzig build test -Dfilter=&quot;filter match function basic&quot;       # run one test\nzig build test -Ddebug-query=true                           # all tests with debug\nzig build compliance                                        # compliance suite\nzig build check                                             # unit tests + compliance</code></pre>\n<p>Once you accept the terms, it’s genuinely refreshing to work with.</p>\n<p>I have Zig to thank, in a roundabout way, for kicking off a bigger chain reaction, namely my move away from a full IDE toward a helix + alacritty + zellij setup.</p>\n<h2 id=\"flat-structure\">Flat structure</h2>\n<p>With Rust, and most other languages, I’ve always spent a fair amount of time (going back and forth) trying to find the right balance\nbetween file size and folder depth. You’re free to fragment files and grow the folder hierarchy as deep as you like. Zig,\nit turned out, is fine with this too, but somehow doesn’t really encourage it (like C, which is no surprise for a low-level systems language).\nYou can nest files and folders if you want, but doing so brings a bit of import friction,\nand the real question becomes: why bother? What do you actually gain in readability by splitting everything across more files and folders? In theory, better readability.\nIn practice, when you collapse related things into one larger file, you can just slice it and navigate section by section instead and there’s a real benefit to having everything in one place.\nMostly, Zig nudges you toward flat. If something needs a companion for a model, I just create a <code>model_&lt;companion&gt;</code> file next to it and move on.</p>\n<p>I don’t think this scales to large projects, meaning at some point you need a real hierarchy but the threshold for needing one turned out to be much higher in Zig than I expected. In Rust, I tend to reach for folder structure early, almost by default. In Zig, I kept deferring it, and by the end of this project, I never needed it at all.</p>\n<p>That contrast was useful beyond just Zig, because it made me reconsider, even in other languages, whether I’m organizing files because the project genuinely needs it, or out of habit. It’s also a pretty honest way to gauge how big a project actually is: if you can’t resist reaching for folders on day one,maybe it’s smaller than it feels.</p>\n<p>Here’s the actual difference, side by side:</p>\n<p>Rust (<code>src/</code>):</p>\n<pre><code>src/\n├── lib.rs\n├── parser.rs\n├── parser/\n│   ├── errors.rs\n│   ├── macros.rs\n│   ├── model.rs\n│   ├── tests.rs\n│   └── grammar/\n│       └── json_path_9535.pest\n├── query.rs\n└── query/\n    ├── atom.rs\n    ├── comparable.rs\n    ├── comparison.rs\n    ├── filter.rs\n    ├── jp_query.rs\n    ├── queryable.rs\n    ├── segment.rs\n    ├── selector.rs\n    ├── state.rs\n    ├── test.rs\n    └── test_function.rs</code></pre>\n<p>Zig (<code>src/</code>):</p>\n<pre><code>src/\n├── root.zig\n├── parser.zig\n├── model.zig\n├── model_query.zig\n└── query.zig</code></pre>\n<h2 id=\"tests\">Tests</h2>\n<p>Setting the rfc9535 compliance suite aside for now and focusing purely on the language itself:</p>\n<p>In Rust, I tend to stick with two approaches to testing:</p>\n<ul><li>Inline unit tests, living in the same file or same folder as the code they cover. This is the convenient default always there, no extra setup.</li><li>Integration tests, in an independent folder (like <code>tests</code> ) outside the main source tree. This is the exception not the default, and sometimes absent altogether.</li></ul>\n<p>I expected roughly the same split from Zig. On paper, it looks similar: you can write tests directly inside the same file. The problem, at least for me, was verbosity. Given the flat structure I’d already settled into, I was left with two options, either a separate <code>model_test</code> file per model, or tests inlined directly into the model file itself. Both approaches ended up cluttering things: either the individual files or the main folder as a whole.</p>\n<p>I went with the second option, which meant configuring it explicitly in <code>build.zig</code>. Once that was wired up, though, it worked well and stayed clean.</p>\n<p>So overall: writing and managing tests feels easier to me in Rust. But in Zig’s case, much of that extra friction is language-specific, it comes down to Zig’s manual memory management rather than testing infrastructure itself.</p>\n<h2 id=\"no-functional-paradigm\">No functional paradigm</h2>\n<p>Rust is technically an imperative language, but it draws heavily on functional concepts: zero-cost iterators, lazy evaluation, ADTs, pattern matching, monadic types, traits, closures, and so on. Having also spent time with Haskell and Erlang, I’ve become fairly inclined toward the functional style, and it shows in this library. It leans heavily on FP idioms:</p>\n<ul><li>Monadic error control via combinators like <code>Queryable</code> and related types</li><li>Monadic-style data types like <code>Data&lt;T&gt;</code> with<code>map</code> ,<code>flat_map</code> ,<code>reduce</code> , and friends</li><li>Pure, immutable transformations</li><li>Combinators over iterators instead of loops</li><li>Closures for local abstraction</li><li>Declarative macros as a small embedded DSL</li><li>Sum types and product types</li></ul>\n<p>I knew going in that I wouldn’t be able to bring all of this to Zig, but I hoped I could at least preserve the core concepts. In practice, where Rust leans on immutability and combinators, Zig pushed me toward in-place mutation and the pattern most native to the imperative world.</p>\n<p>Where the two stay close: <strong>sum types</strong>.</p>\n<p>Pure and direct in Rust:</p>\n<pre><code>pub trait Query {\n    fn process&lt;&#39;a, T: Queryable&gt;(&amp;self, state: State&lt;&#39;a, T&gt;) -&gt; State&lt;&#39;a, T&gt;;\n}\nimpl Query for Segment {\n    fn process&lt;&#39;a, T: Queryable&gt;(&amp;self, step: State&lt;&#39;a, T&gt;) -&gt; State&lt;&#39;a, T&gt; {\n        match self {\n            Segment::Descendant(segment) =&gt; segment.process(step.flat_map(process_descendant)),\n            Segment::Selector(selector) =&gt; selector.process(step),\n            Segment::Selectors(selectors) =&gt; process_selectors(step, selectors),\n        }\n    }\n}</code></pre>\n<p>Duck-typed in Zig:</p>\n<pre><code>pub fn query(node: anytype, iteration: *JsonPathIter) !void {\n    const T = switch (@typeInfo(@TypeOf(node))) {\n        .pointer =&gt; |p| p.child,\n        else =&gt; @TypeOf(node),\n    };\n    if (!@hasDecl(T, &quot;query&quot;)) {\n        return; // no compile-time trait; just checks the method exists\n    }\n    try node.query(iteration);\n}</code></pre>\n<p><strong>Recursion holds up on both sides too</strong>.</p>\n<p>Rust:</p>\n<pre><code>fn process_descendant&lt;T: Queryable&gt;(data: Pointer&lt;T&gt;) -&gt; Data&lt;T&gt; {\n    if let Some(array) = data.inner.as_array() {\n        Data::Ref(data.clone()).reduce(\n            Data::new_refs(/* children */).flat_map(process_descendant)\n        )\n    } else { Data::Nothing }\n}</code></pre>\n<p>Zig:</p>\n<pre><code>fn collectDescendants(allocator, value: *std.json.Value, path, out) !void {\n    try out.append(allocator, .{ .json = value, .path = try allocator.dupe(u8, path) });\n    switch (value.*) {\n        .array =&gt; |arr| for (arr.items) |*elem| try collectDescendants(allocator, elem, child_path, out),\n        else =&gt; {},\n    }\n}</code></pre>\n<p>But the language quickly forces you to diverge from the functional style, mostly because you’re now dealing with allocators directly, and a genuinely pure functional approach means constantly constructing new structures. That’s either expensive in memory or expensive in the manual bookkeeping needed to avoid it.</p>\n<p><strong>Mutation vs. immutable monad is the core difference</strong>.</p>\n<p>Rust does a straightforward monadic transformation:</p>\n<pre><code>pub fn flat_map&lt;F&gt;(self, f: F) -&gt; Data&lt;&#39;a, T&gt; {\n    match self {\n        Data::Ref(data) =&gt; f(data),      // returns a *new* Data\n        Data::Refs(v) =&gt; Data::Refs(v.into_iter().flat_map(...).collect()),\n        _ =&gt; Data::Nothing,\n    }\n}</code></pre>\n<p>Zig switches to mutation:</p>\n<pre><code>pub fn queryName(name: []const u8, iteration: *q.JsonPathIter) !void {\n    while (i &lt; iteration.cursors.items.len) {\n        if (obj.getPtr(name)) |val| {\n            iteration.cursors.items[i] = .{ .json = val, .path = new_path }; // in-place overwrite\n        } else iteration.remove(i);                                          // mutate list directly\n    }\n}</code></pre>\n<p><strong>Reduce vs Fork</strong>.</p>\n<p>Rust:</p>\n<pre><code>selectors.iter().map(|s| s.process(step.clone())).reduce(State::reduce)</code></pre>\n<p>Zig:</p>\n<pre><code>var lhs_branch = try iter.fork();   // deep copy of cursor state\ndefer lhs_branch.deinit();          // then discarded</code></pre>\n<p><strong>Combinators vs. loops</strong>.</p>\n<p>Rust:</p>\n<pre><code>items.iter().enumerate().filter(|(_, i)| cond(i)).map(|(idx, i)| Pointer::idx(i, path, idx)).collect()</code></pre>\n<p>Zig:</p>\n<pre><code>while (i &lt; cursors.len) {\n    if (actual_index &lt; arr.items.len) { cursors[i] = .{...}; i += 1; }\n    else iteration.remove(i);\n}</code></pre>\n<p>All told, this reflects each language’s design goals and target domain, and it’s a reasonable trade-off but subjectively, I found the resulting Zig code less readable than its Rust counterpart.</p>\n<h2 id=\"allocators\">Allocators</h2>\n<p>Allocators are everywhere. Almost every function accepts one; every structure holds one. It’s explicit, and once you accept that as the cost of entry, it’s relatively straightforward to follow. This is more or less the language’s defining feature, so I can’t say I wasn’t warned.</p>\n<p>In practice, though, the process is tedious. You have to meticulously follow the init/deinit convention, and that discipline gets shaky the moment your call stack grows long. It’s a clear improvement over a silent segfault or corrupted memory in C, but coming from Rust, you’re still the one enforcing the rule by hand: allocate something, handle the failure path, decide who’s responsible for deinit, every single time.</p>\n<p>Fortunately, Zig’s <code>TestAllocator</code> comes to the rescue here. It won’t catch everything automatically, you still need to write the test cases that exercise the failure paths — but once you do, it’s fairly reliable. And that’s the trap: this all looks obvious on paper, right up until the code gets more complex, at which point these bugs tangle themselves up and hide.</p>\n<p>Here are the cases that hit hardest, each compared against how Rust handles the same shape:</p>\n<h3 id=\"memory-leak-forgotten-deinit\">Memory leak: forgotten deinit</h3>\n<pre><code>var iter = q.JsonPathIter.init(&amp;root, std.testing.allocator);\ntry iter.append(&amp;root, &quot;$[&#39;a&#39;]&quot;);\n// BUG: no iter.deinit()</code></pre>\n<p><strong>Caught by</strong>: <code>MemoryLeakDetected</code>, pointing at the <code>dupe</code> call inside <code>append</code>.</p>\n<p><strong>Fix</strong>: <code>defer iter.deinit();</code> right after init.</p>\n<p><strong>Rust</strong>: <code>Drop</code> runs automatically at scope end, so this specific bug simply doesn’t exist. Though technically, leaks are still possible in Rust like <code>Rc</code> reference cycles, or an explicit <code>Box::leak</code> so “never leaks” isn’t a hard guarantee, just something you’d have to go out of your way to trigger.</p>\n<h3 id=\"memory-leak-deinit-skipped-on-error-path\">Memory leak: deinit skipped on error path</h3>\n<pre><code>fn build(json: *Value, a: Allocator) !q.JsonPathIter {\n    var iter = q.JsonPathIter.init(json, a);\n    try iter.append(json, &quot;$[&#39;a&#39;]&quot;); // ok\n    try iter.append(json, &quot;$[&#39;b&#39;]&quot;); // fails -&gt; iter leaked\n    return iter;\n}</code></pre>\n<p><strong>Caught by</strong>: <code>FailingAllocator{ .fail_index = 1 }</code>, which forces the second append into <code>MemoryLeakDetected</code>.</p>\n<p><strong>Fix</strong>: <code>errdefer iter.deinit();</code> right after init.</p>\n<p><strong>Rust</strong>: truly eliminated. <code>Drop::drop</code> fires unconditionally on any scope exit, including early returns from <code>?</code>.</p>\n<h3 id=\"memory-corruption-deinit-called-twice\">Memory corruption: deinit called twice</h3>\n<pre><code>fn runQuery(json: *Value, qstr: []const u8, a: Allocator) !q.JsonPathResult {\n    var iter = q.JsonPathIter.init(json, a);\n    errdefer iter.deinit();\n    try q.query(qstr, &amp;iter);\n    return iter.toResult(parsed); // ownership moves to caller\n}\nfn cacheAndLog(json: *Value, qstr: []const u8, a: Allocator, cache: *std.ArrayList(q.JsonPathResult)) !void {\n    var result = try runQuery(json, qstr, a);\n    try cache.append(result);   // cache now holds a (shallow) copy of result&#39;s pointers\n    defer result.deinit();      // BUG: frees the same heap data cache.items still points to\n    printResults(&amp;result);\n}\nfn processAll(json: *Value, queries: [][]const u8, a: Allocator) !void {\n    var cache = std.ArrayList(q.JsonPathResult).init(a);\n    defer {\n        for (cache.items) |*r| r.deinit();  // frees the SAME memory Layer 2 already freed\n        cache.deinit();\n    }\n    for (queries) |qs| try cacheAndLog(json, qs, a, &amp;cache);\n}</code></pre>\n<p><strong>Caught by</strong>: running under <code>std.testing.allocator</code>, which fails on the second query’s <code>cache.items[0].deinit()</code> during <code>processAll</code>’s cleanup, <code>DoubleFree</code> pointing at both free sites, confirming this is a cross-function ownership bug, not a single-line typo.</p>\n<p><strong>Fix</strong>: only one layer may own the value. Since <code>cache</code> outlives <code>cacheAndLog</code>, ownership belongs to layer three; layer two must not <code>defer deinit</code> after handing it off:</p>\n<pre><code>fn cacheAndLog(json: *Value, qstr: []const u8, a: Allocator,\n                cache: *std.ArrayList(q.JsonPathResult)) !void {\n    var result = try runQuery(json, qstr, a);\n    printResults(&amp;result);      // use it first\n    try cache.append(result);   // then hand off ownership — no defer after this\n}</code></pre>\n<p><strong>Rust</strong>: this exact shape can’t compile. <code>cache.push(result)</code> moves <code>result</code> — after that line, <code>result</code> no longer exists as a usable binding, so there’s no way to later call <code>drop(result)</code> by accident.</p>\n<h3 id=\"memory-corruption-orphaned-allocation-when-moving-into-a-struct-\">Memory corruption: orphaned allocation when moving into a struct fails</h3>\n<pre><code>pub fn appendBuggy(self: *Iter, v: *Value, path: []const u8) !void {\n    const duped = try self.allocator.dupe(u8, path);\n    // BUG: no errdefer\n    try self.cursors.append(self.allocator, .{ .json = v, .path = duped });\n}</code></pre>\n<p><strong>Caught by</strong>: <code>FailingAllocator{ .fail_index = 1 }</code> failing the array’s growth (the second allocation), orphaning <code>duped</code> (the first allocation).</p>\n<p>This leaks in a way distinct from case one: <code>iter.deinit()</code> runs fine, it just never sees this particular string.</p>\n<p><strong>Fix</strong>:</p>\n<pre><code>const duped = try self.allocator.dupe(u8, path);\nerrdefer self.allocator.free(duped);   // only fires if append below fails\ntry self.cursors.append(self.allocator, .{ .json = v, .path = duped });</code></pre>\n<p><strong>Rust</strong>: true by construction. <code>Vec::push(item)</code> moves <code>item</code> in and either succeeds or aborts on OOM and there’s no fallible push in the standard API that hands you back an “allocated but unlinked” value to accidentally lose. The gap that <code>errdefer</code> fills here simply doesn’t exist to begin with.</p>\n<h2 id=\"libraries-and-the-core-api\">Libraries and the core API</h2>\n<p>The ecosystem is still very young. There’s a real scarcity of libraries, and even something as basic as regex isn’t fully mature, for instance <code>mvzr</code>, the regex engine available in Zig, doesn’t support Unicode property escapes (<code>\\p{...}</code>), which surfaced directly as a gap while implementing RFC 9535’s filter functions. On top of that, the language’s own standard library changes its API from version to version. None of this was surprising going in, but it’s worth noting for the record.</p>\n<h2 id=\"overall-impression\">Overall impression</h2>\n<p>The language is different from Rust (who could’ve thought that, yeah), but it left a genuinely good impression. It’s straightforward, modern, and blazingly fast. I believe it has real potential to become the true successor to C. On the other hand, it’s still young, and it shows: the shape of the language itself feels unfinished in places, and I suspect it’ll pick up more of the cooler quality-of-life features and syntax sugar as it matures.</p>\n<p>As for me, I’d like to keep contributing to the ecosystem, and I will, whenever I come across a project worth building.</p>\n<h2 id=\"links\">Links</h2>\n<p><em>Disclaimer: styling and error handling throughout this article were cleaned up with the help of AI.</em></p>","headings":[{"level":1,"text":"What Zig felt like, coming from Rust","id":"what-zig-felt-like-coming-from-rust"},{"level":2,"text":"Intro","id":"intro"},{"level":2,"text":"IDE support","id":"ide-support"},{"level":2,"text":"Flat structure","id":"flat-structure"},{"level":2,"text":"Tests","id":"tests"},{"level":2,"text":"No functional paradigm","id":"no-functional-paradigm"},{"level":2,"text":"Allocators","id":"allocators"},{"level":3,"text":"Memory leak: forgotten deinit","id":"memory-leak-forgotten-deinit"},{"level":3,"text":"Memory leak: deinit skipped on error path","id":"memory-leak-deinit-skipped-on-error-path"},{"level":3,"text":"Memory corruption: deinit called twice","id":"memory-corruption-deinit-called-twice"},{"level":3,"text":"Memory corruption: orphaned allocation when moving into a struct fails","id":"memory-corruption-orphaned-allocation-when-moving-into-a-struct-"},{"level":2,"text":"Libraries and the core API","id":"libraries-and-the-core-api"},{"level":2,"text":"Overall impression","id":"overall-impression"},{"level":2,"text":"Links","id":"links"}]}}