Skip to content(if available)orjump to list(if available)

Zig; what I think after months of using it

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.

toprerules

As a systems programmer, Rust has won. It will take decades before there is substantial Rust replacing the absurd amounts of C that runs on any modern Unix system, but I do believe that our of all the replacements for C/C++, Rust has finally gained the traction most of them have lacked at the large companies that put resources behind these types of rewrites and exploratory projects.

I do not think Zig will see wide adoption, but obviously if you enjoy writing it and can make a popular project, more power to you.

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.

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

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!

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.

smt88

The debate seems to have mostly ended in a victory for static types.

The largest languages other than Python have them (if you include the transition from JS to TS). Python is slowly moving toward having them too.

Turskarama

I honestly don't see how anyone who has used a language with both unions and interfaces could come up with anything else that makes dynamic types better.

Either way you need to fulfill the contract, but I'd much prefer to find out I failed to do that at compile time.

ridiculous_fish

Don't confuse "presence of dynamic types" with "absence of static types."

Think about the web, which is full of dynamicism: install this polyfill if needed, call this function if it exists, all sorts of progressive enhancement. Dynamic types are what make those possible.

edflsafoiewq

The whole anytype/trait question is just dynamic typing, but at the type level instead of the value level.

null

[deleted]

patrick451

If I'm told to still use === in typescript, it's not actually a statically typed language.

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.

lolinder

> Replacing the value of one variable constantly throughout the code could lead to unpredictable bugs.

Having variables with scopes that last longer than they're actually used and with names that are overly long and verbose leads to unpredictable bugs, too, when people misuse the variables in the wrong context later.

When I have `initial_foo`, `foo_feature_a`, and `foo_feature_b`, I have to read the entire code carefully to be sure that I'm using the right `foo` variant in subsequent code. If I later need to drop Feature B, I have to modify subsequent usages to point back to `foo_feature_a`. Worse, if I need to add another step to the process—a Feature C—I have to find every subsequent use and replace it with a new `foo_feature_c`. And every time I'm modifying the code later, I have to constantly sanity check that I'm not letting autocomplete give me the wrong foo!

Shadowing allows me to correctly communicate that there is only one `foo` worth thinking about, it just evolves over time. It simulates mutability while retaining all the most important benefits of immutability, and in many cases that's exactly what you're actually modeling—one object that changes from line to line.

lnenad

> When I have `initial_foo`, `foo_feature_a`, and `foo_feature_b`, I have to read the entire code carefully to be sure that I'm using the right `foo` variant in subsequent code. If I later need to drop Feature B, I have to modify subsequent usages to point back to `foo_feature_a`. Worse, if I need to add another step to the process—a Feature C—I have to find every subsequent use and replace it with a new `foo_feature_c`. And every time I'm modifying the code later, I have to constantly sanity check that I'm not letting autocomplete give me the wrong foo!

When you have only one `foo` that is mutated throughout the code you are forced to organize the processes in your code (validation, business logic) based on the current state of that variable. If your variables have values which are logically assigned you're not bound by the current state of that variable. I think this a big pro. The only downside most people disagreeing with me are mentioning is related to ergonomics of it being more convenient.

saithound

Shadowing always has been a feature, doubly so in languages which lack linear types.

It is a promise to the reader (and compiler) that I will have no need of the old value again.

Notice that applying the naming convention you suggest does nothing to prevent the bug in the code you quoted. It might be just as easy to write

const initial_foo = Foo.init(); > const foo_feature_A = try initial_foo.addFeatureA(); > const foo_feature_B = try initial_foo.addFeatureB();

but it's also just as wrong. And even if you get it right, when the code changes later, somebody may add const foo_feature_Z = try foo_feature_V.addFeatureX();. Shadowing prevents this.

nine_k

Said promise should also be checked for sanity. E.g.

  for i in range(N) {
    for i in range(M) {
      # Typo; wanted j.
      # The compiler should complain.
    }
  }

Maxatar

The Rust compiler would complain in this case that the initial i variable is unused. Unused variables should be named with an underscore, _.

dpc_01234

Shadowing is a feature. It's very common that given value transforms its shape and previous versions become irrelevant. Keeping old versions under different names would be just confusing. With type system there is no room for accidental misuse. I write Rust professionally for > 2 years, and years before that I was using it my own projects. I don't think shadowing ever backfired on me, while being very ergonomic.

lnenad

Depending on which language you are using shadowing could lead to either small issues or catastrophic ones (in the scope of the program). If you have Python and you start with a number but end up with a complex dict this is very different than having one value in Rust and a slightly different value which is enforced by the compiler.

zamalek

The example given isn't that great. Here's a significantly more common one:

    var age = get_string_from_somewhere();
    var age = parse_to_int(age);
Without same-scope shadowing you end up with the obnoxious:

    var age_string = get_string_from_somewhere();
    var age = parse_to_int(age_string);
Note that your current language probably does allow shadowing: in nested scopes (closures).

chrisco255

Changing the type on a value is an anti-pattern, in my opinion. It's not obnoxious to be explicit in your variable names.

Maxatar

Don't see how it could introduce bugs. The point of replacing a variable is precisely to make a value that is no longer needed inaccessible. If anything introducing new variables with new names has the potential to introduce subtle bugs since someone could mistakenly use one of the variables that is no longer valid or no longer needed.

sjburt

When you are modifying a long closure and don’t notice that you are shadowing a variable that is used later.

I know “use shorter functions” but tell that to my coworkers.

physicles

Over the years, I’ve wasted 1-2 days of my life debugging bugs caused by unintentional variable shadowing in Go (yes, I’ve kept track). Often, the bug is caused by an accidental use of := instead of =. I don’t understand why code that relies on shadowing isn’t harder to follow. Wish I could disable it entirely.

lolinder

> Often, the bug is caused by an accidental use of := instead of =.

This is a distinctly Go problem, not a problem with shadowing as a concept. In Rust you'd have to accidentally add a whole `let` keyword, which is a lot harder to do or to miss when you're scanning through a block.

There are lots of good explanations in this subthread for why shadowing as a concept is great. It sounds like Go's syntax choices make it bad there.

lnenad

> There are lots of good explanations in this subthread for why shadowing as a concept is great

Not really. All of them boil down to ergonomics, when in reality it doesn't bring a lot of benefit other than people hating on more descriptive variable names (which is fair).

pcwalton

You can (assuming you're talking about Rust)! Just use Clippy and add #[deny(clippy::shadow_reuse)]: https://rust-lang.github.io/rust-clippy/master/#shadow_reuse

My position on shadowing is that it's a thing where different projects can have different opinions, and that's fine. There are good arguments for allowing shadowing, and there are good arguments for disallowing it.

antonvs

It’s been a feature in languages for at least half a century. Scheme’s lexical scoping supported it in 1975, and Lisp adopted that.

lnenad

Yeah, it's a feature of a language, doesn't mean we are forced to use it.

cwood-sdf

It seems like he wants zig to be more like rust. personally, i like that zig is so simple

zamalek

This is absolutely not what the article is about. A good majority of it is spent on the myth that Zig is safer than Rust, which has nothing to do with wishing Zig was more like Rust.

chrisco255

Is there a myth that makes that claim? Virtually every take I've heard is that Zig is "safe enough" while giving developers more control over memory and actually, it's specifically better for cases where you must write unsafe code, as it's not possible to express all programs in safe Rust.

bobbylarrybobby

If you must write unsafe code, what's wrong with just dropping down to unsafe in Rust when you need to? You have all the power unsafe provides, and you have a smaller surface area to audit than if your entire codebase resides in one big unsafe block.

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?

jamii

What they're trying to convey is that errors are structurally typed. If you declare:

    const MyError = error{Foo}
in one library and:

    const TheirError = error{Foo}
in another library, these types are considered equal. Unlike structs/unions/enums which are nominal in zig, like most languages.

The reason for this, and the reason that errors are not regular records, is to allow type inference to union and subtract error types like in https://news.ycombinator.com/item?id=42943942. (They behave like ocamls polymorphic variants - https://ocaml.org/manual/5.3/polyvariant.html) This largely avoids the problems described in https://sled.rs/errors.html#why-does-this-matter.

On the other hand zig errors can't have any associated value (https://github.com/ziglang/zig/issues/2647). I often find this requires me to store those values in some other big sum type somewhere which leads to all the same problems/boilerplate that the special error type should have saved me from.

naasking

I'm not speaking for Zig, but in principle errors are not values, and often have different control flow and sometimes even data flow constraints.

valenterry

Can you elaborate more?

lmm

> What does "global to your whole program" mean? I'd expect types to be available to the whole compilation unit.

I think they mean you only have one global/shared ErrorType . You can't write the type of function that may yeet one particular, specific type of error but not any other types of error.

chrisco255

They're really just enum variants. You can easily capture the error and conditionally handle it:

fn failFn() error{Oops}!i32 { try failingFunction(); return 12; }

test "try" { const v = failFn() catch |err| { try expect(err == error.Oops); return; }; try expect(v == 12); // is never reached }

lmm

> You can easily capture the error and conditionally handle it

Sure. But the compiler won't help you check that your function only throws the errors that you think it does, or that your try block is handling all the errors that can be thrown inside it.

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.

pcwalton

> typeclasses/traits are, internally, virtual classes with a vtable pointer

No, they're not. Rust "boxed traits" are, but those aren't what the author means.

> 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.

The standard library not being equipped to handle Unicode is the entire problem. Not solving it doesn't avoid the issue: it just makes Unicode safety the programmer's responsibility, increasing the complexity of the problem domain for the programmer and leaving more room for error.

metaltyphoon

> The standard library not being equipped to handle Unicode is the entire problem

Zig: I want to be a safer C

C: I don't have string type

Zig: No… not like that!

llimllib

> In pretty much every "Unicode string" library/language type I've seen, each individual codepoint is a "character"

languages are actually really inconsistent on what they count as a unicode character: https://hsivonen.fi/string-length/

(I don't broadly disagree with you on unicode support, just linking an article relevant to that claim)

mbb70

There is no nebulous 'character' type. There are bytes, codepoints and glyphs. All languages with Unicode support allow iterating over each for a given string.

caspper69

Having just gone down this road in C#, the way Unicode is now handled is via "runes".

Each rune may be comprised of various Unicode characters, which may themselves be 1-4 bytes (in the case of utf-8 encoding).

The one problem I have with this approach is that all of the categorization features operate a level below the runes, so you still have to break them up. The biggest drawback is that, at least in my (admittedly limited) research, there is no such thing as a "base" character in certain runes (such as family emojis- parents with kids). You can mostly dance around it with the vast majority of runes, because one character will clearly be the base character and one (or more) will clearly be overalys, but it's not universal.

silisili

Go does this too. I generally like the idea a lot, as long as it's consistent. The one thing I don't like is the inconsistency.

Not sure about C#, but in Go for example ranging strings ranges over runes, but indexing pulls a single byte. And len is the byte length rather than rune length.

So basically it's a byte array everywhere except ranging. I guess I would have preferred an explicit cast or conversion to do that instead of by default.

stonogo

Runes are how UTF-8 has been handled since its invention. It's just taken some platforms longer to get there than others.

edflsafoiewq

> Zig does have destructors, in a way. It's called defer and errordefer.

defer ties some code to a static scope. Destructors are tied to object lifetime, which can be dynamic. For example, if you want to remove some elements from an ArrayList of, say, strings, the string's would need to be freed first. defer does not help you, but destructors would.

wtetzner

I don't necessarily disagree with not having a string type in a low level language, but you seem very fixated on needing a character type. Why not just have string be an opaque type, and have functions to iterate over code points, grapheme clusters, etc.?

chrisco255

For me not having strings in Zig and being forced to use the fairly verbose '[]const u8' syntax every time I need a string was a little annoying at first, but it has had the effect of making me comfortable with the idea of buffers in a general sense, which is critical in systems programming. Most of the things that irked me about Zig when first learning it (I'm only a few weeks into it) have grown on me.

null

[deleted]

nynx

Typeclasses are conceptual interfaces. They don’t have anything to do with vtables.