Isaac Parker

Choosing a generic delimiter

Notes from figuring out how to represent generics in a language syntax for a work-in-progress compiler. (If you’ve never had to think about it before, turns out language design is hard).

Goals:

This basically means we need to use an assymetric paired delimeter, of which there are 4 options in the ASCII character set: [a], {a}, <a>, & (a). A symmetric paired delimiter1, such as |a| (used in Rust for closure args), I find less readable as the “enclosure” is not visually obvious.

We already use () for calls and {} for blocks, which rule those out as natural options for generic syntax, because they would make disambiguation tricky for both the compiler and anyone reading the code. So, we’re left with two options, both widely used: <> (TypeScript, Java, C++, Rust) or [] (Go, Python, Scala).

Angle brackets

Angle brackets seem promising at first glance, because as paired delimiters, they have no other role in the language syntax. < and > are used as binary operators, but we should be able to disambiguate: delimiters are paired, binary operators are standalone.

The Type Parameters Proposal from Go gives this example explaining why they choose to not use angle brackets:

a, b = w < x, y > (z)

Without type information, it is impossible to decide whether the right hand side of the assignment is a pair of expressions (w < x and y > (z)), or whether it is a generic function instantiation and call that returns two result values ((w<x, y>)(z)). [source]

Since we don’t have tuple assignment, that specific example isn’t applicable. But consider:

x<y>(z)

Is this a call of function x with generic argument y and argument z, or is this a boolean expression (x < y) > z? Rust disambiguates by using the turbofish in expression position (x::<y>(z);), which is visually costly. Resolving this decision requires either a grammar convention, parser speculation, restricted syntax, or extra punctuation.

Square brackets

Square brackets avoid syntactic ambiguity, as [ and ] aren’t used as operators. However, they introduce semantic ambiguity, because we also use square brackets for indexed access of collections:

item := items[0]

Go also overloads [] for indexing & collection types. In many cases parsing can disambiguate from position alone, but consider these examples, where the role of the brackets cannot be known without type information:

var f map[string]func()
var X = "key"

f[X]() // Look up key X in map f, then call the result.
func f[T any]() { /* ... */ }
type X int

f[X]() // Instantiate generic function f with type X, then call it.

For the single-argument case, Go doesn’t solve this in the parser; the syntax f[x] is represented as a general IndexExpr node, and later type analysis figures out which of the two it actually is.

Clearly this approach must work well enough, so I gave it a shot and rewrote our AST to use a single “Subscript” AST node. It worked fine for the happy path:

foo[bar]()

In a valid program where foo and bar are both resolved, the compiler has no problem figuring out what this is. Unfortunately, much of the effort in compiler development is spent on making invalid programs behave nicely: produce clean, valuable diagnostics, generate as much type information as possible for editor features, and of course don’t crash.

I had set up automated compiler fuzzing to identify crashes on compiler invariants, and over-and-over again I discovered crashes on code such as:

receiver.unresolved[X]()
receiver.unresolved[List[Int64]]()
receiver.unresolved[unresolved]()
unresolved[unresolved]()

Even after an error, the compiler still needs to analyze every nested expression. But when the target is unresolved, the shared node leaves it unclear whether the bracket contents should follow the type or value path, and some expressions fell through the cracks.

While each bug could be patched in isolation, the common root cause was the complexity due to the shared node making intent unavailable exactly where robust recovery needed intent. The compiler then duplicated recovery logic across call, method, type, and index paths.

Note: another approach could be reserve f[X]() for generic calls and require parenthesized (f[X])() to call an indexed function value. But this only disambiguates calls, not type application itself. If a fully specialized generic function can act as a value, then callback := f[X] remains ambiguous.

Sigil character

Ultimately I ended up investigating a less common approach: using a sigil character before the delimiters to identify generics. The closest precedent I could find is D, which uses ! before parentheses containing template instantiation arguments ():

auto arr = makeArray!(int)(10);

The bang / exclamation mark is what I’ve landed on, as its vertical bar shape is the least noisy, and completely disambiguates syntactically with only minimal visual cost. I kept square brackets rather than D’s parentheses to visually distinguish type arguments from value arguments, and also went a step further than D by using the same uniform syntax for type declaration, meeting our goal of one consistent visual form:

x![y](z)

type Box![T] struct { ... }

  1. Technically any character could be used as a symmetric paired delimiter. 🙃 ↩︎