Zig; what I think after months of using it
- 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 with
const
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<i32>
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<T> {
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<A, E>
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 are built-ins (so you don’t get to pay for the complexity of it).
Another thing I dislike about try
is that it’s a prefix keyword which chains really badly:
try (try foo()).bar()
Shadowing is forbidden
Shadowing is the act of using the same name for two different bindings available in a scope hierarchy. Rust is an excellent example of how to do shadowing correctly. For instance, in Rust:
fn foo(input: &str) {
// input refers to the argument and has type &str
let input = input.to_owned();
// input refers to this function stackframe local owned string and has type String
// we don’t have access to the argument anymore
{
let input = 12;
// input is integer, and we don’t have access to the String in this block
}
// input is the String
}
This lexical scoping in Rust prevents tons of boilerplate or having to come up with useless names. Zig not having that causes annoyances, especially with error handling:
const foo = Foo.init();
const foo2 = try foo.addFeatureA();
const foo3 = try foo.addFeatureB();
This is maybe a sign of the design choice (no move semantics), but all in all, I don’t like having to come up with either obscure names or just bad names for something that should be chained calls, or just reusing the same name. Sometimes, naming things is something we should refrain from.
Compile-time duck typing
This is an important issue I have with Zig, as it’s present everywhere and implies a lot of cascaded issues.
comptime
is not only a keyword, as you might have guessed. Take this example for instance:
fn doMath(x: anytype) @TypeOf(x) {
// …
}
There is no way to know what that function requires as input. The very first line of the zig zen
states
* Communicate intent precisely.
And even though I do agree with that, I think it’s poorly implemented, for two reasons:
- Requiring users to read the code of a function is everything but a good and precise way to communicate intent.
- Requiring users to read the documentation is flawed by design.
The second point is even violated by the standard library itself. Consider std.fs.Dir.iterate, which is a function used to iterate on a directory entries. There is no documentation at all, nor even comment, and you are left with its implementation, which is:
pub fn iterate(self: Dir) Iterator {
return self.iterateImpl(true);
}
You can click on Iterator to end up on another
documentation page with no actual documentation and a lengthy implementation. I highly suggest reading the comment
on the next()
function:
/// Memory such as file names referenced in this returned entry becomes invalid
/// with subsequent calls to `next`, as well as when this `Dir` is deinitialized.
This is incredibly error-prone, and the mere fact that even the standard library fails to abide by the very first
rule of zig zen
proves the point that communicating intent precisely via documentation is hard, and probably not a
good design decision for a modern language.
And there is worse. As your software matures and you write more and more code, you will eventually change some internals. Imagine what would happen if the documentation and/or comments go out of sync with the code. In languages with type systems, it’s a minor annoyance — you will get angry people complaining about the fact the documentation has typos / errors, and fixing them will be a patch release. In a language such as Zig where the documentation is the contract, the source of the issue is vague and harder to understand: do they misuse the function? Is the function buggy? Both?
No typeclasses / traits
Another point that I want to discuss is the lack of traits. Zig is a simple language and per-se, I guess, traits were ruled out — and don’t even think about proposing them. However, they do solve a real-world problem: programming contract. There are two important parts:
-
Programming contracts convey useful information to the programmers. If you have a function like the
doMath
presented above, you have no clue what you are supposed to call it with. If you have something like:fn do_math<T>(input: T) T where T: Add + Mul { // … }
You know exactly what you can call it with. Additionally, you get discoverability — it’s super easy to build a reverse list of implementations based off some traits — Rust does it for you in both the docs and LSP.
-
Programming contracts are part of the public-facing side of your API. They should be items that are versioned as the rest. If you decide to change the allowed properties of an input, just updating the documentation will make it harder to realize you have to do a minor bump and not a patch bump.
Moreover, typeclasses allow for more control on the code you don’t run / test. If someone decides to implement your trait for their types, having a clear interface to implement is far sounder than having to go fishing for the programming contract in the documentation, assuming it even exists in the first place.
comptime
is probably not as interesting as it looks
I’ve been wondering a lot about this one lately. Yes, compile-time reflection is nice… but is it really?
When you think about it, implementing a JSON serializer for anytype
is probably very unlikely to be
completely true. There will be hidden constraints. For instance, does it make sense to serialize a
pointer? Probably not, so the implementation might check the type info and refuse to serialize a
pointer. And — c.f. the previous section — that information is missing from the interface.
When you think about it, this problem is everywhere, as soon as you use comptime
, because of the lack
of traits. If you want to understand the contracts / preconditions / postconditions / invariants… just
read the documentation… and the code, because well, you can’t be sure that the doc is not out of sync.
comptime
is one of those features that feel like a candy, but has a bitter taste to it. It feels like
C++ templates back again, and the community will not address that.
I hate the idea. After decades of programming, I can say with full certainty that besides teams with extremely talented people that have been doing that kind of things for more than I’ve lived (old C programmers, Linux maintainers, maybe a bunch of driver maintainers), it will only end up with tears. I can’t in good faith suggest to use Zig at work given how complex our programming contracts are. I’m far from being a perfect programmer — I make mistakes. We do have junior developers in our teams, work or spare-time projects, people more or less used to our code bases and guidelines, and we are not 100% perfect while reviewing either. Sometimes some code will be reviewed by someone else that is less severe than you are. Sometimes it’s your future-self that will actually misuse something you thought was easy to understand. Etc.
No encapsulation
This is a continuation of the previous section. For a reason that I don’t understand, Zig doesn’t have
encapsulation. What it means is that if you use a type such as ArrayList(i32)
, you must know how it’s
implemented to be able to get useful functions and properties, such as its len
length. Still with the
same example, the documentation of ArrayList
doesn’t show anything regarding the length of the array, nor even a way to iterate on its
elements. You have to know — read the code please — that what you are looking for is .items.len
for the
length. If you want to use the for
loop syntax, you do it on the .items
:
for (array.items) |item| {
// …
}
Even worse, you can actually mutate the internals. How do we ensure that we can expose types to our users
without causing them to break invariants? Well, zig zen
: communicate intent precisely, of course! Redirect
them to the code and ensure to slap // INVARIANT
in your comments / documentation.
Sigh…
I’m a bit dishonest, though. Zig does have a form of access control. At the module level, you can decide
whether a symbol is private (default), or pub
. That’s all you get.
In essence, this is similar to C.
Memory safety is highly underestimated and fallacious
I read in numerous places that Zig is safer than unsafe
Rust, which is not true, and fallacious.
Andrew Kelley wrote a blog article
where he built his argumentation on the fact that Rust will compile and will run into UB, while Zig won’t.
Here’s the Rust code:
struct Foo {
a: i32,
b: i32,
}
fn main() {
unsafe {
let mut array: [u8; 1024] = [1; 1024];
let foo = std::mem::transmute::<&mut u8, &mut Foo>(&mut array[0]);
foo.a += 1;
}
}
The first thing to say is that there is indeed a UB, and it’s not easy to spot; transmuting a &mut u8
to
&mut Foo
is the same as casting an aligned pointer to an unaligned pointer, which is indeed UB. Back when
he wrote his article, I’m not sure he knew about miri — which has been around since
October 2017 — given that his article was released in 2018,
but even then, I think it’s important to draw people’s attention to something important here. Zig does have
this unaligned pointer cast detection by default, but you’re just one rustup components add miri
call away:
cargo miri run
…
error: Undefined Behavior: constructing invalid value: encountered an unaligned reference (required 4 byte alignment but found 1)
--> src/main.rs:9:19
|
9 | let foo = std::mem::transmute::<&mut u8, &mut Foo>(&mut array[0]);
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ constructing invalid value: encountered an unaligned reference (required 4 byte alignment but found 1)
|
= help: this indicates a bug in the program: it performed an invalid operation, and caused Undefined Behavior
= help: see https://doc.rust-lang.org/nightly/reference/behavior-considered-undefined.html for further information
= note: BACKTRACE:
= note: inside `main` at src/main.rs:9:19: 9:74
note: some details are omitted, run with `miriFLAGS=-Zmiri-backtrace=full` for a verbose backtrace
error: aborting due to 1 previous error; 1 warning emitted
It would have been nice to point that out. Yes, Zig has it by default, but Rust has it too by adding a tool
that is available via rustup
. I do have to agree that unsafe
Rust is probably harder to write than
regular Zig, because of all the rules you have to manually ensure not to violate (stacked borrows, etc.), and
for 90% of the time, what you will be doing will be to call a C function via the FFI, so you should be fine.
For more complicated unsafe
usage, just use miri. Actually, I think it should be a good practice to use
miri, at least in the CI.
I could understand Andrew missed that when he wrote his article (hm…). But today? People still mention that Zig is sooooo much safer than Rust.
So let’s see how Zig is safer now. Up to this day, Zig doesn’t see Use After Free (UAF). Not at compile-time. Not at runtime. They are just plain Undefined Behavior (UB). For instance:
const std = @import("std");
fn getPtr(value: i32) *i32 {
var x = value;
x += 1;
return &x;
}
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
const ptr = getPtr(123);
try stdout.print("ptr.* = {d}\n", .{ptr.*});
ptr.* += 10;
try stdout.print("ptr.* = {d}\n", .{ptr.*});
}
If you compile this with the default optimization option (Debug
), you get this:
$ zig build run
ptr.* = 124
ptr.* = 134
If you compile with ReleaseFast
:
$ zig build -Doptimize=ReleaseFast run
ptr.* = 0
ptr.* = 0
Oh le malaise. This, here; this exact situation is the main reason why I refrain myself from using Zig
in production. The idea that I could end up in such a situation and not even knowing about it. There
are discussions to track all possible UB and turn them into checked illegal behavior.
That issue has been opened since 2019. I’m not saying it won’t ever be a thing — I guess it’s required
to hit 1.0. But still. The language cannot in good faith state that it’s safer than unsafe
Rust. Let’s
convert that code to Rust and run miri on it…
I would obviously never suggest to use raw pointers but instead references, but references will trigger a compilation error so it’s not really fair for this comparison, and the Zig community always compares it to
unsafe
Rust, so let’s get unsafe!
fn get_ptr(input: i32) -> *mut i32 {
let mut value = input;
value += 1;
&mut value as *mut _
}
fn main() {
unsafe {
let ptr = get_ptr(123);
println!("*ptr = {}", *ptr);
*ptr += 10;
println!("*ptr = {}", *ptr);
}
}
Running miri:
error: Undefined Behavior: out-of-bounds pointer use: alloc565 has been freed, so this pointer is dangling
--> src/main.rs:10:31
|
10 | println!("*ptr = {}", *ptr);
| ^^^^ out-of-bounds pointer use: alloc565 has been freed, so this pointer is dangling
|
= help: this indicates a bug in the program: it performed an invalid operation, and caused Undefined Behavior
= help: see https://doc.rust-lang.org/nightly/reference/behavior-considered-undefined.html for further information
help: alloc565 was allocated here:
--> src/main.rs:2:9
|
2 | let mut value = input;
| ^^^^^^^^^
help: alloc565 was deallocated here:
--> src/main.rs:5:1
|
5 | }
| ^
= note: BACKTRACE (of the first span):
= note: inside `main` at src/main.rs:10:31: 10:35
= note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
The message has some weird mentions in (alloc565
), but the actual useful information is there: a pointer is
dangling.
So no, I strongly disagree that Zig is safer than — even — unsafe
Rust. Anyone telling you otherwise is
either purposefully lying, or ignorant. And I think I need to mention the misconception that you need to
drop to unsafe
often in Rust. This is simply not true. Some libraries — especially interfacing with C
libraries — do use unsafe
to make FFI calls (usually, they just do that). unsafe
might be required for
some very specific tricks required to implement safer abstractions, but you are not supposed to write a full
library or application in unsafe
.
Just test your software correctly!
Again that argument… UB cannot be tested and requires statical analysis — or some kind of runtime protections that is currently not implemented in Zig — and coverage in a langage that is built off lazy compilation everywhere is probably not something I will discuss here…
Lazy compilation and compilation errors instead of warnings
Heck, I thought I would dodge this one. So… yeah… this is a bit embarrassing, but Zig implements lazy compilation.
The idea is that you write code, and it doesn’t compile it. That sounds so exotic that the standard library
has a helper function to mitigate that weird design decision (std.testing.refAllDecls
).
You must use it in a very natural way:
test {
std.testing.refAllDecls(some_module_of_yours);
}
It’s a common idiom I have seen in many places (Ghostty 1 2 3 4; TigerBeetle 1 2; vulkan-zig hmm).
So… if everyone really mitigates this “feature”… was it really worth it? In the end, it makes sense not to include code that is not participating in the final binary, but… have you thought about refactoring? Have you thought about systems that add code that will be used later? That happens a lot and I was bitten so many times while writing some disjoint sections of code and having to spend much more time later when gluing everything together — because the code was actually never checked!
And there is the opposite problem. Zig makes some assumptions on what is important, so obviously, a parameter of a function that is not used should be a hard error. It lazy-checks functions you wrote and ignores them if you don’t use them right away, but refuses to compile arguments you ignore?! I mean, I get why (not using an argument could hide a bug), but a warning would suffice.
I haven’t found a way to disable that linter and make it a warning, and I think it’s unlikely to ever happen.
No destructors
This one I could get, but not implemented the way it is. Zig doesn’t have any sound way to ensure proper resource management — just like C. See the previous section about communicating intent properly. Zig requires the call site to deallocate properly, and you have to mention the deinit logic in your documentation.
defer
– and errdefer
, which is for a different usecase, but it’s not really important here — is a tool you
can use at call site to implement resource cleanup, whether it’s memory deallocation or file descriptors close.
The concept has been around for a long time, and as mentioned in the previous sentence, it’s not automatic. The
caller must know that they have to call defer
. The documentation might forget to mention it and the user
might forget to call it. On memory cleanup, if you are lucky and your tests run that code, you will get a traced
leak. For more complex resources such as GPU queues, database handles, etc., well, it’s probably a leaked file
descriptor?
I’m not entirely sure whether destructors are the best solution to this problem, but they allow to ensure that
the code calls the cleanup routine. There are alternatives — explored in ATS, probably too complex
for now, requiring linear types and/or proofs to force the caller to get rid of the resource — Rust could have
something along those lines, since it has move semantics and an affine type system; I don’t think people will
trade Drop
for linear types though.
It’s a bit a pity, to be honest, to see such a design in Zig, because it does have the infrastructure to do better in my humble opinion. For instance, this won’t compile:
fn foo() i32 { return 123; }
// …
pub fn main() !void {
foo();
}
Because the return value of foo
is ignored. To make that compile, you need to explicitly ignore the
returned value:
pub fn main() !void {
_ = foo();
}
So it’s a bit weird to see that Zig can prevent compilation if you do not use an argument or the returned
value of a function, but doesn’t provide the proper tooling to force the user to correctly deinit
resources. If you pull the problem a bit more, it shows that the design of Zig — in its current state —
doesn’t permit that, since you would need linear types/values (i.e. a value must be used once and only
once). I would have loved something like a #
linear marker that would cause a linear resource to be dangling
if it’s not eventually consumed by a function taking it with the marker as argument:
const Resource = struct {
…
pub fn init() #Resource {
…
}
pub fn deinit(r: #Resource) {
}
};
Obviously, as soon as you see that, it causes issues with the Zig type system, because you can still bit-copy values, so you duplicate a resource and might cause double-frees — but copying should be disallowed in a linear type system. So Zig cannot have linear types/values without changing all the properties of such types, and purely linear values are often not enough; we eventually want to weaken the restriction (relevant type system) to be able to use the resource value in a controlled way, via, for instance, borrowing. Which leads to more complexity in the language, so clearly not something we will ever see in Zig.
No (unicode) strings
The standard library doesn’t have a good support for unicode strings. There is a discussion opened by
Drew DeVault about improving strings and unicode handling.
I share the link so that you make your own opinion about what to think of all that, but not having a
string type and recommending users to “iterate on bytes” is a big no to me. It’s the open door to a wide variety
of issues / corrupted data. People in the thread even recommend using .len
on []u8
to get length of strings
(never do that, unless you are doing Advent of Code).
It will never happen, whatever your arguments. You are left with user-land support, such as zg.
Conclusion: simplicity rhymes with unrestricted power, which rhymes with…
I get it. Zig has the ambition to replace C, and as such, doesn’t want to have to deal with complex abstractions. It trades memory safety and soundness for simplicity. If I’ve been around Zig and actually writing Zig code that much, it’s because I wanted to check whether memory safety is a red herring myself. In the end, maybe Zig is right. Maybe we can achieve enough with a simple language, and deal with the remaining issues with other approaches. There is nothing wrong with that.
However, I think it’s too soon to make any useful conclusion. I really want to have a look at reports about projects that are entirely written in Zig (Ghostty, TigerBeetle, etc.) after they have matured enough. It’s great that those projects are successful with Zig; I’m honestly happy for them. But a robust and scientific approach requires us to go further than just assumptions and feelings. I do think we have data about CVE issues (we all know the 70% number, right?), and it took time and lots of software history to have enough hindsight. I do think that hindsight is required on Zig to know whether it’s actually contributing to more reliable software. Very personal opinion: given all the points I mentioned, I really doubt it.
I don’t think that simplicity is a good vector of reliable software. At most, it’s a happy side-effect. It’s not a requirement, and should remain a secondary mission. What the industry needs is to identify problems (we have) and designs solutions that solve those problems. Anyone has the right to select a subset of those problems (even Rust can’t solve everything) and solve those specifically, ignoring the others or pushing their resolution to user-land / process etc. But here, I think there is a big misconception.
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?
Remember the introduction:
Focus on debugging your application rather than debugging your programming language knowledge.
Do you see why this is such a poignant take? Is it really better to spend time in gdb
/ whatever debugger
you are using, or having your users opening bug issues than having to spend a bit more time reading compiler
error messages? That mantra seems to make it like it’s a bad thing to have a compiler yell at you because you
are very likely doing something you don’t really intend to. Why? It’s a well known fact that bugs don’t
write themselves. At some point, a developer wrote some code expressing a solution that was, in fact,
actually written a different way, in dissonance with the initial intent. This is part of being a human. So
why would you complain that a compiler tells you that what you are doing is very likely wrong? Because in a
few instances, it was unable to see that what you were doing was, actually, fine? You base a full language
experience based solely on some exceptional occurrences / issues? And trust me, I also do have those moments
with Rust from time to time (especially with the lack of view types and partial mutable borrows).
Seeking simple is just not the right move to me. To me, a more robust approach is ensuring people can’t shoot themselves, while seeking simpler. Simpler doesn’t mean simple; it means that you design a solution, whatever the complexity, and tries to make it as simple as possible. Rust is honestly not that hard, but it is definitely not a simple language. However, for all the problems it solves at compile-time, it’s definitely simpler than all other approaches (e.g. ATS). And it’s not unlikely that it will get simpler and simpler as we discover new ways of expressing language constructs.
I think my adventure with Zig stops here. I have had too frustration regarding correctness and reliability concerns. Where people see simplicity as a building block for a more approachable and safe-enough language, I see simplicity as an excuse not to tackle hard and damaging problems and causing unacceptable tradeoffs. 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.