- What I like
- What I like less
- Error handling
- Shadowing is forbidden
- Compile-time duck typing
- No typeclasses / traits
comptime
is probably not as interesting as it looks- No encapsulation
- Memory safety is highly underestimated and fallacious
- Lazy compilation and compilation errors instead of warnings
- No destructors
- No (unicode) strings
- Conclusion: simplicity rhymes with unrestricted power, which rhymes with…
Ah, Zig. I have a love-hate relationship with this one. A “new” (reading: appeared a couple years ago,
already — yes, already), language with high ambitions. Zig was made to run at low-level, with a simple design
to solve many problems C has (macros, allocators, error handling, more powerful types like baked-in tagged
unions and bitsets, a better build system, no hidden control flow, etc.). The language claims to be the C
successor, and today, many people claim that Zig is simpler and even safer than most languages out there —
even Rust! — allowing to focus more on the technical challenges around your problem space rather
than — quoting from the Zig mantra — your language knowledge. I think I need to put the full mantra
because I will reuse it through this article:
Focus on debugging your application rather than debugging your programming language knowledge.
We will come back to that.
I had already written about Zig a while ago when I
initially approached it. I thought the language was really interesting and I needed to dig deeper. That blog
article was made in July, 2024. I’m writing these lines in February, 2025. Time has passed, and yet I have been
busy rewriting some Rust code of mine in Zig, and trying out new stuff not really easy or doable in Rust, in
Zig, just to see the kind of power I have.
Today, I want to provide a more matured opinion of Zig. I need to make the obvious disclaimer that because I
mainly work in Rust — both spare-time and work — I have a bias here (and I have a long past of Haskell projects
too). Also, take notice that Zig is still in its pre-1.0 era (but heck, people still mention that Bun,
Tigerbeetle, Ghostty are all written in Zig, even though it hasn’t reached 1.0).
I split this article in two simple sections:
- What I like about Zig.
- What I dislike about Zig.
What I like
Arbitrary sized-integers and packed structs
Zig has many interesting properties. The first one that comes to mind is its arbitrary-sized integers. That
sounds weird at first, but yes, you can have the regular u8
, u16
, u32
etc., but also u3
. At first it
might sound like dark magic, but it makes sense with a good example that is actually a defect in Rust to me.
Consider the following code:
struct Flags {
bool clear_screen;
bool reset_input;
bool exit;
};
// …
if (flags.clear_screen || flags.reset_input) {
// …
}
That is some very typical need: you want a set of flags (booleans) and depending on their state, you want to
perform some actions. Usually — at least in C, but really everyone should do it this way — we don’t represent
such flags as structs of booleans, because booleans are — most of the time — 8-bit integers. What it means is
that sizeof(Flags)
here is 3
bytes (24 bits, 8 * 3). For 3 bits of information. So what we do instead is
to use a single byte and perform some bitwise operations to extract the bits:
#define FLAGS_CLEAR_SCREEN 0b001
#define FLAGS_RESET_INPUT 0b010
#define FLAGS_EXIT 0b100
struct Flags {
uint8_t bits;
};
bool Flags_contains(Flags const* flags, uint8_t bit) {
return flags.bits & bit != 0;
}
Flags Flags_set(Flags flags, uint8_t bit) {
flags.bits |= bit;
return flags;
}
Flags Flags_unset(Flags flags, uint8_t bit) {
flags.bits &= ~bit;
return flags;
}
That is obviously very error-prone: we use CPP macros (yikes), bits are not properly typed, etc. Zig can
use its arbitrary-sized integer types and packed structs to automatically implement similar code:
const Flags = packed struct {
clear_screen: bool,
reset_input: bool,
exit: bool,
};
This structure has two sizes: its bit-size, and its byte-size. The bit-size represents the minimum
number of bits it uses (3), and the byte-size represents the number of bytes required to hold the type
(1). We can then use it like so:
if (flags.clear_screen or flags.reset_input) {
// …
}
This is an awesome feature, especially because lots of C libraries expect such bitfields, for instance
in the form of a u32
. You can easily and naturally convert the Flags
type to a u32
with
@bitCast(flags)
— you need to ensure the booleans are in the right order (big endianness here in Zig
if I recall correctly).
Note: in Rust, we don’t really have a nice way to do this without requiring a dependency on
bitflags, which still requires you to provide the binary value of each logical boolean in binary,
usually done withconst
expressions using1 << n
..
Generic types are just functions at the type level
As a Haskeller, this is also something that makes a lot of sense to me. A typical struct Vec
in
most languages is actually a function taking a type
and returning a type
in Zig;
fn Vec(comptime T: type) type
.
Although more verbose, it allows a lot of flexbility, without introducing a new layer specific to the type
system. For instance, specialization can be written in the most natural way:
fn Vec(comptime T: type) type {
if (@sizeOf(T) == 0) {
return VecAsUsize;
} else {
return struct {
// …
};
}
Another use case that I think is pretty nice is when you need to implement something that depends on the actual
type structure. Zig has compile-time reflection, which means that you can analyze the fields, type information
etc. of a type to implement a specialized version of your algorithm. You can then write your JSON serializer
without depending on a middleware (e.g. in Rust, serde).
Error Union Types
This one is a half love / half dislike — you will find the dislike part in the appropriate section of this article.
In Zig, the core of error handling is Error Union Types. It’s straight-forward: take an enum
(integer tags)
and glue it with a regular T
value in a tagged union. You either get the error discriminant, or your
T
value. In Rust terms:
enum ErrorUnion {
Err(ErrorType),
Ok(T),
}
There’s a catch, though. Unlike Rust, ErrorType
is global to your whole program, and is nominally typed. Error
types are declared with the error {}
construct:
const MyError = error {
FileNotFound,
NoPermision,
};
Error types can be glued together to create more complex error types:
const OtherError = error {
OOM,
NotANumber,
};
const AppError = MyError || OtherError;
Thus, an Error Union Type is either an error or a value, and it’s written E!T
(E
the error type, T
the value). An interesting aspect of that is that all error types are flat (there is no nesting), and
because they are nominal, you can even return error values without declaring them in the first place. If
you do not care about the actual type of your error, you can use the anyerror
special type to refer to
the global error type, or leave it empty (!T
) to infer the type based on the body of the function.
All of that is interesting, but one very cool aspect that I think I really miss when writing Rust is
coercion. Because of coercion rules, a regular value T
coerces to E!T
, and an error type E
coerces to
E!T
. So you can completely write this:
fn foo() !i32 {
return 3;
}
And the same is true for void
:
fn checkOrError(input: i32) !void {
if (input < 10) {
return error.TooSmall;
}
}
There is no need to wrap results in “success paths”, such as Ok(())
in Rust.
C interop is probably the best
If you need to work with C libraries a lot, Zig has some really good features and built-ins baked-in,
especially if you combine them with comptime
functions to perform various transformations automatically.
@cImport / @cInclude
allow to read a .h
, parse it at compile-time, and expose its content as Zig
symbols (functions, constants, etc.), exposed in a big struct. For instance:
const c = @cImport({
@cInclude("GLFW/glfw3.h");
});
// c.glfwInit() is now available
This is honestly pretty nice, especially since you can iterate on the contents of c
with an inline for
at comptime
to transform functions the way you want.
The build system is nice
The build configuration of your project is written in Zig. Even though I don’t think I like configuration as
code, it’s still an interesting idea. It will probably feel like fresh air for
people having to use CMake or similar tools. Zig build module is not very complex to understand and allows a
great deal of flexibility when configuring your targets, optimizations, CPU architectures, ABI, CLI options,
steps and all.
At the current time of writing this, however, even zig
does build and package, dependency handling is far
behind anything you might be used to (cargo
, pip
, node
, cabal
, etc.). I don’t think it would be fair
to judge it for now.
What I like less
Even tough Zig is full of nice surprises, it’s also full of what I would call flaws that — personal
opinion – make it a bad fit for robust and sound systems.
Error handling
As mentioned earlier, error handling in Zig is nice, but it lacks one important feature: you can’t carry
values. It’s likely due to the fact errors flow via a different path than “normal” code, and require owned
values, so in many cases it will require allocations, which is not something Zig wants on its error path.
It makes sense, but it’s really annoying. Something like error.FileNotFound
will require you extra
code infrastructure to find exactly which file was not found — maybe you can deduce it from the caller
and match on the error via catch
— but maybe you can’t (the path might be computed by the function
returning the error). You can’t even pass integers around in errors, since it’s already used for the
variant of the error itself.
Coming from Rust, obviously, that feels very weak. The Result
type — that I’ve been using in Haskell
as Either e a
— is a godsend. Not having something like that in Zig creates frustration and will likely
generate less interesting error values, or more convoluted code infrastructure around the callers.
On a similar note, the try
keyword (which takes an E!A
expression and is equal to A
or return E;
,
depending on the presence of error) allows to propagate errors, but it won’t do much more than that. You can
think of try foo()
as the same as foo() catch |err| return err;
. That obviously works only with
error union types, so if you happen to have a function returning a ?A
(optional), you can’t shortcircuit
in a nice way with try
and instead needs to use the more verbose orelse return null;
. This monadic
approach is pretty powerful in Haskell and Rust, and it wouldn’t hurt much to allow it for error union
types and optionals, since both
14 Comments
taurknaut
I loved this deep-dive of zig.
> There’s a catch, though. Unlike Rust, ErrorType is global to your whole program, and is nominally typed.
What does "global to your whole program" mean? I'd expect types to be available to the whole compilation unit. I'm also weirded out by the fact that zig has a distinct error type. Why? Why not represent errors as normal records?
3r7j6qzi9jvnve
(never used zig yet myself)
For UB detection I've read zig had prime support for sanitizers, so you could run your tests with ubsan and catch UBs at this point… Assuming there are enough tests.
As far as I'm concerned (doing half C / half rust) I'm still watching from the sidelines but I'll definitely give zig a try at some point.
This article was insightful, thank you!
lnenad
When did shadowing become a feature? I was under the impression it's an anti-pattern. As per the example in the article
> const foo = Foo.init();
> const foo2 = try foo.addFeatureA();
> const foo3 = try foo.addFeatureB();
It's a non issue to name vars in a descriptive way referring to the features initial_foo for example and then foo_feature_a. Or name them based on what they don't have and then name it foo. In the example he provided for Rust, vars in different scopes isn't really an example of shadowing imho and is a different concept with different utility and safety. Replacing the value of one variable constantly throughout the code could lead to unpredictable bugs.
scubbo
Great write-up, thank you!
I used Zig for (most of) Advent Of Code last year, and while I did get up-to-speed on it faster than I did with Rust the previous year, I think that was just Second (low-level) Language syndrome. Having experienced it, I'm glad that I did (learning how cumbersome memory management is makes me glad that every other language I've used abstracts it away!), but if I had to pick a single low-level language to focus on learning, I'd still pick Rust.
cwood-sdf
It seems like he wants zig to be more like rust. personally, i like that zig is so simple
ethin
No idea how much the author is experienced at Zig, but my thoughts:
> No typeclasses / traits
This is purposeful. Zig is not trying to be some OOP/Haskell replacement. C doesn't have traits/typeclasses either. Zig prefers explicitness over implicit hacks, and typeclasses/traits are, internally, virtual classes with a vtable pointer. Zig just exposes this to you.
> No encapsulation
This appears to be more a documentation issue than anything else. Zig does have significant issues in that area, but this is to be expected in a language that hasn't even hit 1.0.
> No destructors
Uh… What? Zig does have destructors, in a way. It's called defer and errordefer. Again, it just makes you do it explicitly and doesn't hide it from you.
> No (unicode) strings
People seem to want features like this a lot — some kind of string type. The problem is that there is no actual "string" type in a computer. It's just bytes. Furthermore, if you have a "Unicode string" type or just a "string" type, how do you define a character? Is it a single codepoint? Is it the number of codepoints that make up a character as per the Unicode standard (and if so, how would you even figure that out)? For example, take a multi-codepoint emoji. In pretty much every "Unicode string" library/language type I've seen, each individual codepoint is a "character". Which means that if you come across a multi-codepoint emoji, those "characters" will just be the individual codepoints that comprise the emoji, not the emoji as a whole. Zig avoids this problem by just… Not having a string type, because we don't live in the age of ASCII anymore, we live in a Unicode world. And Unicode is unsurprisingly extremely complicated. The author tries to argue that just iterating over byes leads to data corruption and such, but I would argue that having a Unicode string type, separate from all other types, designed to iterate over some nebulous "character" type, would just introduce all kinds of other problems that, I think, many would agree should NOT be the responsibility of the language. I've heard this criticism from many others who are new to zig, and although I understand the reasoning behind it, the reasoning behind just avoiding the problem entirely is also very sensible in my mind. Primarily because if Zig did have a full Unicode string and some "character" type, now it'd be on the standard library devs to not only define what a "character" is, and then we risk having something like the C++ Unicode situation where you have a char32_t type, but the standard library isn't equipped to handle that type, and then you run into "Oh this encoding is broken" and on and on and on it goes.
edflsafoiewq
The debate between static and dynamic typing continues unceasingly. Even when the runtime values are statically typed, it's merely reprised at the type level.
SPBS
Headers are missing IDs for URL fragments to jump to e.g. https://strongly-typed-thoughts.net/blog/zig-2025#error-hand… doesn't work
sedatk
> The first one that comes to mind is its arbitrary-sized integers. That sounds weird at first, but yes, you can have the regular u8, u16, u32 etc., but also u3. At first it might sound like dark magic, but it makes sense with a good example that is actually a defect in Rust to me.
You don't need Rust to support that because it can be implemented externally. For example, crates like "bitbybit" and "arbitrary-int" provide that functionality, and more:
https://docs.rs/crate/arbitrary-int/
https://docs.rs/crate/bitbybit/
hoelle
> Zig does enhance on C, there is no doubt. I would rather write Zig than C. The design is better, more modern, and the language is safer. But why stop half way? Why fix some problems and ignore the most damaging ones?
I was disappointed when Rust went 1.0. It appeared to be on a good track to dethroning C++ in the domain I work in (video games)… but they locked it a while before figuring out the ergonomics to make it workable for larger teams.
Any language that imbues the entire set of special characters (!#*&<>[]{}(); …etc) with mystical semantic context is, imo, more interested in making its arcane practitioners feel smart rather than getting good work done.
> I don’t think that simplicity is a good vector of reliable software.
No, but simplicity is often a property of readable, team-scalable, popular, and productive programming languages. C, Python, Go, JavaScript…
Solving for reliability is ultimately up to your top engineers. Rust certainly keeps the barbarians from making a mess in your ivory tower. Because you're paralyzing anyone less technical by choosing it.
> I think my adventure with Zig stops here.
This article is a great critique. I share some concerns about the BDFL's attitudes about input. I remain optimistic that Zig is a long way from 1.0 and am hoping that when Andrew accomplishes his shorter-term goals, maybe he'll have more brain space for addressing some feedback constructively.
grayhatter
lol, I knew exactly who wrote this once I saw the complaint about shadowing being forbidden. The author and I were just arguing about it the other day on irc. While the author considers it an annoying language bug because it requires creating additional variable names (given refactoring was an unpalatable option). I consider it a feature.
Said arguments have become a recurring and frustrating refrain; when rust imposes some limit or restriction on how code is written, it's a good thing. But if Zig does, it's a problem?
The remainder of the points are quite hollow, far be it from me to complain when someone starts with a conclusion and works their way backwards into an argument… but here I'd have hoped for more content. The duck typing argument is based on minimal, or missing documentation, or the doc generator losing parts of the docs. And "comptime is probably not as interesting as it looks" the fact he calls it probably uninteresting highlights the lack of critical examination put here. comptime is an amazing feature, and enables a lot of impressive idioms that I enjoy writing.
> I’m also fed up of the skill issue culture. If Zig requires programmers to be flawless, well, I’m probably not a good fit for the role.
But hey, my joke was featured as the closing thought! Zig doesn't require one to be flawless. But it' also doesn't try to limit you, or box you into a narrow set of allowed operations. There is the risk that you write code that will crash. But having seen more code with unwrap() or expect() than without, I don't think that's the bar. The difference being I personally enjoy writing Zig code because zig tries to help you write code instead of preventing you from writing code. With that does come the need to learn and understand how the code works. Everything is a learnable skill; and I disagree with the author it's too hard to learn. I don't even think it's too hard for him, he's just appears unwilling…. and well he already made up his mind about which language is his favorite.
frangfarang
[dead]
ibraheemdev
> The message has some weird mentions in (alloc565), but the actual useful information is there: a pointer is dangling.
The allocation ID is actually very useful for debugging. You can actually use the flags `-Zmiri-track-alloc-id=alloc565 -Zmiri-track-alloc-accesses` to track the allocation, deallocation, and any reads/writes to/from this location.
k0tran
[dead]