Since we're talking syntax... it's mildly infuriating that the zig parser is not smart enough to understand expressions like `const x=a()orelse b();`. You have to manually add a space before `orelse` -- but isn't that what `zig fmt` is for? I have RSI and it's maddening having to mash the arrow keys and add/remove whitespace until the parser is happy.
I've heard the argument that people might confuse binary operators for prefix/postfix operators, but I don't buy it. Who would think an operator named `orelse` is anything but binary?
It's still not clear to me how you can make two comptime closures with different contents and pass those as a functor into the same function. It needs to have a sort of VTable to invoke the function, and yet since the contents are different, the objects are different, and their deallocation will be different too.
Defining VTable in zig seems to be a pretty laborious endeavor, with each piece sewn manually.
Everyone agrees that "syntax doesn't matter", but implicit in that is "syntax doesn't matter, so let's do what I prefer". So really, syntax does matter. Personally I prefer the Rust/Zig/Go syntax of vaguely C inspired with some nice fixes, as detailed in the post. Judging by the general success of that style, I do wonder if more functional languages should consider an alternative syntax in that style. The Haskell/OCaml concatenative currying style with whitespace is elegant, but sufficiently unfamiliar that I do think it hurts adoption.
After all, Rust's big success is hiding the spinach of functional programming in the brownie of a systems programming language. Why not imitate that?
> Like Rust, Zig uses 'name' (':' Type)? syntax for ascribing types, which is better than Type 'name'
I'm definitely an outlier on this given the direction all syntactically C-like new languages have taken, but I have the opposite preference. I find that the most common reason I go back to check a variable declaration is to determine the type of the variable, and the harder it is to visually find that, the more annoyed I'm going to be. In particular, with statically typed languages, my mental model tends to be "this is an int" rather than "this is a variable that happens to have the type 'int'".
In Rust, in particular, this leads to some awkward syntactic verbosity, because mutable variables are declared with `let mut`, meaning that `let` is used in every declaration. In C or C++ the type would take the place of that unnecessary `let`. And even C (as of C23) will do type inference with the `auto` keyword. My tendency is to use optional type inference in places where needing to know the type isn't important to understand the code, and to specify the type when it would serve as helpful commentary when reading it back.
Libellous! The "spiral rule" for C is an abomination and not part of the language. I happen to find C's type syntax a bit too clever for beginners, but it's marvellously consistent and straightforward. I can read types fine without that spiral nonsense, even if a couple of judicious typedefs are generally a good idea for any fancy function types.
Zig is just fun to write. And to me, it’s actually what I wish Rust was like. Rust is a great language, and no one’s going to argue that point but writing Zig for the first time was so refreshing. That said, Rust is now basically default for systems and Zig came too late.
The use of Ruby block parameters for loops in a language without first-class blocks/lambdas is a bit weird to me. I might have preferred a syntax that looks more like normal variable binding.
> Note all three are monosyllable, unlike const and fn!
I'm not sure if I'm parsing this right, but is the implication that "const" is not monosyllabic? It certainly is for me. How else do people say it? I get "fn" because people might say "eff enn" but I don't see what the equivalent for const would be.
that was my favourite bit in the entire post - the one place where zig has unambiguously one-upped other languages. the problems it is solving are:
1. from the user's point of view, you can now have multiline string literals that are properly indented based on their surrounding source code, without the leading spaces being treated as part of the string
2. from an implementation point of view having them parsed as individual lines is very elegant, it makes newline characters in the code unambiguous and context independent. they always break up tokens in the code, regardless of whether they are in a string literal or not.
Weird the author finds it lovely and compares to Kotlin, but doesn't find Kotlin superior. Kotlin invested heavily in a really nice curly brace syntax. It is actually the nicest out there. In every point the author makes, it feels like Kotlin did it the same or better. For example:
1. Integer literals. "var a = 1" doesn't work, seems absurd. In Kotlin literals do have strong types, but coercion is allowed when defining variables so "var a = 1" works, and "var a: Long = 1" works even though you can write a literal long as 1L. This means you can write numbers to function parameters naturally.
2. Multi-line string literals. OK this is a neat idea, but what about copy/paste? In Kotlin you can just write """ .. """.trimIndent() and then copy paste some arbitrary text into the string, the indent will be removed for you. The IDE will also help with this by adding | characters which looks more natural than \\ and can be removed using .trimMargin(), only downside is the trimming is done at runtime but that could easily be fixed without changing the language.
3. Record literals. This syntax is called lovely because it's designed for grep, a properly funded language like Kotlin just uses named kwargs to constructors which is more natural. There's no need to design the syntax for grep because Kotlin is intended to be used with a good IDE that can answer this query instantly and precisely.
4. Function syntax. "fn foo(a: i32) i32 {}" seems weird. If the thing that has a type and the type are normally separated by a : then why not here? Kotlin does "fun foo(a: Int): Int {}" which is more consistent.
5. Locals. Agree with author that Kotlin nails it.
6. Not using && or ||, ok this one Zig wins, the Zig way is more consistent and reads better. Kotlin does have `and` and `or` as infix operator functions, but they are for the bitwise operations :(
7. Explicit returns. Kotlin supports blocks that return values and also doesn't need semicolons, so not quite sure what the tradeoff here is supposed to be about.
8. Loops being expressions is kinda cool but the Kotlin equivalent of his example is much easier to read still: "val thing = collection.first { it.foo > bar }". It compiles to a for loop due to the function inlining.
9. Generics. Zig's way seems primitive and unnecessarily complex. In Kotlin it is common to let the compiler infer generics based on all available information, so you can just write "someMethod(emptyList())" and emptyList<T>() infers to the correct type based on what someMethod expects.
Overall Zig looks like a lot of modern languages, where they are started as a hobby or side project of some guy and so the language is designed around whatever makes implementing the compiler most convenient. Kotlin is unusual because it doesn't do that. It was well funded from the start, so the syntax is designed first and foremost to be as English-like and convenient as possible without leaving the basic realm of ordinary curly-brace functions-and-oop style languages. It manages to be highly expressive and convenient without the syntax feeling overly complex or hard to learn.
> As Zig has only line-comments, this means that \n is always whitespace.
Do I read this correctly that it replaces `\n` at the end of the line with a whitespace? CJK users probably won't be happy with the additional whitespaces.
Zig is great. I have fun writing in it. But there’re a few things bug me.
- Difficult to return a value from a block. Rust treats the value of the last expression of a block as the return value of the block. Have to jump through hoops in Zig to do it with label.
- Unable to chain optional checks, e.g. a?.b?.c. Support for monadic types would be great so general chaining operations are supported.
- Lack of lambda support. Function blocks are already supported in a number of places, i.e. the for-loop block and the catch block.
this is a really, really good article with a lot of nuance and a deep understanding of the tradeoffs in syntax design. unfortunately, it is evoking a lot of knee-jerk reactions from the title and emotional responses to surface level syntax aesthetics.
the thing that stands out to me about Zig's syntax that makes it "lovely" (and I think matklad is getting at here), is there is both minimalism and consistency to the design, while ruthlessly prioritizing readability. and it's not the kind of surface level "aesthetically beautiful" readability that tickles the mind of an abstract thinker; it is brutalist in a way that leaves no room for surprise in an industrial application. it's really, really hard to balance syntax design like this, and Zig has done a lovely and respectable job at doing so.
Very interesting, Zig seems really nice. There was a severe lack of resources when I tried to get into it few years ago, it is nice to see that situation improving in real time.
The part about integer literal is similar to Mojo, with a comp-time type that has to be materialized to another type at runtime. In Mojo though this can be done implicitly so you don't need explicit casting.
37 comments
[ 3.0 ms ] story [ 62.1 ms ] threadI've heard the argument that people might confuse binary operators for prefix/postfix operators, but I don't buy it. Who would think an operator named `orelse` is anything but binary?
Zig has some nice things going on but somehow code is really hard to read, admitting its a skill issue as im not that versed in zig.
This surprises me (as a C++ guy). I use lambdas everywhere. What's the standard way of say defining a comparator when sorting an array in Zig?
After all, Rust's big success is hiding the spinach of functional programming in the brownie of a systems programming language. Why not imitate that?
I'm definitely an outlier on this given the direction all syntactically C-like new languages have taken, but I have the opposite preference. I find that the most common reason I go back to check a variable declaration is to determine the type of the variable, and the harder it is to visually find that, the more annoyed I'm going to be. In particular, with statically typed languages, my mental model tends to be "this is an int" rather than "this is a variable that happens to have the type 'int'".
In Rust, in particular, this leads to some awkward syntactic verbosity, because mutable variables are declared with `let mut`, meaning that `let` is used in every declaration. In C or C++ the type would take the place of that unnecessary `let`. And even C (as of C23) will do type inference with the `auto` keyword. My tendency is to use optional type inference in places where needing to know the type isn't important to understand the code, and to specify the type when it would serve as helpful commentary when reading it back.
Libellous! The "spiral rule" for C is an abomination and not part of the language. I happen to find C's type syntax a bit too clever for beginners, but it's marvellously consistent and straightforward. I can read types fine without that spiral nonsense, even if a couple of judicious typedefs are generally a good idea for any fancy function types.
But sure, if you only compare it with Rust, it is a big improvement.
At least in my pronunciation, all five of those are monosyllabic! (/kQnst/, /f@n/).
(Nice to see someone agree with me on minimizing syllable count, though — I definitely find it easier to chunk[1] fewer syllables.)
[1]: https://en.m.wikipedia.org/wiki/Chunking_(psychology)
The use of Ruby block parameters for loops in a language without first-class blocks/lambdas is a bit weird to me. I might have preferred a syntax that looks more like normal variable binding.
I'm not sure if I'm parsing this right, but is the implication that "const" is not monosyllabic? It certainly is for me. How else do people say it? I get "fn" because people might say "eff enn" but I don't see what the equivalent for const would be.
1. from the user's point of view, you can now have multiline string literals that are properly indented based on their surrounding source code, without the leading spaces being treated as part of the string
2. from an implementation point of view having them parsed as individual lines is very elegant, it makes newline characters in the code unambiguous and context independent. they always break up tokens in the code, regardless of whether they are in a string literal or not.
1. Integer literals. "var a = 1" doesn't work, seems absurd. In Kotlin literals do have strong types, but coercion is allowed when defining variables so "var a = 1" works, and "var a: Long = 1" works even though you can write a literal long as 1L. This means you can write numbers to function parameters naturally.
2. Multi-line string literals. OK this is a neat idea, but what about copy/paste? In Kotlin you can just write """ .. """.trimIndent() and then copy paste some arbitrary text into the string, the indent will be removed for you. The IDE will also help with this by adding | characters which looks more natural than \\ and can be removed using .trimMargin(), only downside is the trimming is done at runtime but that could easily be fixed without changing the language.
3. Record literals. This syntax is called lovely because it's designed for grep, a properly funded language like Kotlin just uses named kwargs to constructors which is more natural. There's no need to design the syntax for grep because Kotlin is intended to be used with a good IDE that can answer this query instantly and precisely.
4. Function syntax. "fn foo(a: i32) i32 {}" seems weird. If the thing that has a type and the type are normally separated by a : then why not here? Kotlin does "fun foo(a: Int): Int {}" which is more consistent.
5. Locals. Agree with author that Kotlin nails it.
6. Not using && or ||, ok this one Zig wins, the Zig way is more consistent and reads better. Kotlin does have `and` and `or` as infix operator functions, but they are for the bitwise operations :(
7. Explicit returns. Kotlin supports blocks that return values and also doesn't need semicolons, so not quite sure what the tradeoff here is supposed to be about.
8. Loops being expressions is kinda cool but the Kotlin equivalent of his example is much easier to read still: "val thing = collection.first { it.foo > bar }". It compiles to a for loop due to the function inlining.
9. Generics. Zig's way seems primitive and unnecessarily complex. In Kotlin it is common to let the compiler infer generics based on all available information, so you can just write "someMethod(emptyList())" and emptyList<T>() infers to the correct type based on what someMethod expects.
Overall Zig looks like a lot of modern languages, where they are started as a hobby or side project of some guy and so the language is designed around whatever makes implementing the compiler most convenient. Kotlin is unusual because it doesn't do that. It was well funded from the start, so the syntax is designed first and foremost to be as English-like and convenient as possible without leaving the basic realm of ordinary curly-brace functions-and-oop style languages. It manages to be highly expressive and convenient without the syntax feeling overly complex or hard to learn.
trimMargin and trimIndent (as well as other operations like concatenation) actually get handled at compile time if the string isn't interpolated.
Such a good decision. There's no reason to use block comments in 2025.
Do I read this correctly that it replaces `\n` at the end of the line with a whitespace? CJK users probably won't be happy with the additional whitespaces.
- Difficult to return a value from a block. Rust treats the value of the last expression of a block as the return value of the block. Have to jump through hoops in Zig to do it with label.
- Unable to chain optional checks, e.g. a?.b?.c. Support for monadic types would be great so general chaining operations are supported.
- Lack of lambda support. Function blocks are already supported in a number of places, i.e. the for-loop block and the catch block.
the thing that stands out to me about Zig's syntax that makes it "lovely" (and I think matklad is getting at here), is there is both minimalism and consistency to the design, while ruthlessly prioritizing readability. and it's not the kind of surface level "aesthetically beautiful" readability that tickles the mind of an abstract thinker; it is brutalist in a way that leaves no room for surprise in an industrial application. it's really, really hard to balance syntax design like this, and Zig has done a lovely and respectable job at doing so.
The part about integer literal is similar to Mojo, with a comp-time type that has to be materialized to another type at runtime. In Mojo though this can be done implicitly so you don't need explicit casting.