Notes
2026/01/28

Trailing arguments, keyword arguments, infix grouping, and tuples

I'm revisiting some of Kombucha's syntax. The goal is to have a syntax that is both minimal and general (in the sense that almost no syntax is privileged and everything is just a user-defined function) while also being less of a soup of parentheses than most Lisps are.

The basic idea is that...

The idea of minimal-but-general syntax and trailing arguments are borrowed from Koka (where you can write foo { ... } without needing to wrap the block as foo({...})), while keyword arguments such as if (condition) do: { ... } else: { ... } are borrowed from Elixir (via Ruby) and desugar to if(condition, [["do", {...}], ["else", {...}]]).

In Elixir, keywords such as do: and else: don't need postfix colons in most situations, which leads to pretty nice syntax, but raises the question of how to disambiguate a combination of infix calls and keyword arguments (in a language like Kombucha which doesn't restrict infix operators to a fixed set).

Trailing arguments, keywords arguments

Here are two options for keyword arguments:

// Option 1: `:` for keywords, infix allowed after keywords
try {
    // ...
} catch: error! as: [resume, arg] => {
    // ...
}

vs.

// Option 2: no `:` for keywords, no infix allowed after keywords
try {
    // ...
} catch error! as ([resume, arg] => {
    // ...
})

Advantages of keywords-with-colons:

Advantages of keywords-without-colons:

Since being explicit about syntax and being able to use infix calls as keyword arguments is more important to me than sticking as closely as possible to existing language syntax, Kombucha is using option 1.

Infix grouping, tuples

Previously, Kombucha used (x, y, ...) exclusively for function arguments and a single expression like (expression) to disambiguate nested infix calls. In other words, (expression) was semantically the same as expression, and multiple items wrapped in parens were only allowed as function call syntax. A collection of values was always written as [x, y, ...]. As a result, function parameters in anonymous functions had to be wrapped in [...], like [x, y] => f(x, y).

But what if the language needs to support different kinds of sequential data, such as arrays of boxed elements (which thus each take up the same space in memory, allowing for O(1) access) as well as tuples of fields with differents lengths? What if we wanted to use [...] for arrays and (...) for tuples?

Rust uses (...) for both tuples and grouping, which works because (x) is not a tuple, it's the same as x. Single element tuples have to be written with a trailing , in Rust, like (x,).

Here's another option for Kombucha: Anything wrapped in `(...)` is always a tuple, except when it's used on the left or right hand side of an infix function and contains a single element, in which case it's interpreted as parens meant to disambiguate. To use a single element tuple as an argument of an infix function, it needs to be wrapped in additional parens, like ((x)) f y, which is the same as f((x), y).

This also leads to a pleasant syntax for anonymous functions, because the (x, y) in (x, y) => { ... } is a two element tuple, whereas the (x) in (x) => { ... } can be simplified to x => { ... }, just like in JS.

Putting it all together (trailing arguments + keyword calls + infix calls + tuples):

try {
    // ...
} catch: error! as: (resume, arg) => {
    // ...
}

if (x == y) {
    // ...
} elif: (x == z) do: {
    // ...
} else: {
    // ...
}

do {
    // ...
} while: { ... }

while { ... } {
    // ...
}

All while remaining minimal and general.