comptime
is probably not as interesting as it looksAh, 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:
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:
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:
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
constexpressions using1 << n..
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).
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:
There’s a catch, though. Unlike Rust, ErrorType is
global to your whole program, and is structurally typed. Error
types are declared with the error {} construct:
Error types can be glued together to create more complex error types:
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:
And the same is true for void:
There is no need to wrap results in “success paths”, such as
Ok(()) in Rust.
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:
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 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.
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.
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:
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 foo2.addFeatureB();
const foo4 = try foo3.addFeatureC();
// etc.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.
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:
There is no way to know what that function
requires as input. The very first line of the
zig zen states
And even though I do agree with that, I think it’s poorly implemented, for two reasons:
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:
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?
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:
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.
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:
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.
ERRATUM (Fri Feb 7 2025): criticism was made regarding this section for being unfair, and after several days, I think I do agree with it. I initially mentioned miri because it’s easy to get it via
rustupand feels baked in, but a honest comparison would have be to compare it to Zig + Valgrind (or similar).
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 emittedIt 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:
If you compile with ReleaseFast:
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
unsafeRust, 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 statistical 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…
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:
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.
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:
Because the return value of foo is ignored. To
make that compile, you need to explicitly ignore the returned
value:
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:
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.
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.

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.