Nothing to it

Unified tuples and structs in Ribbon: A brainstorming journey

February 28, 2025

Welcome back! This entry is a little different than my previous ones. I'll be describing some of my brainstorming that lead to just one of Ribbon's features: the unification of tuples and structs. This will be pretty thorough, but I hope you'll enjoy the journey just as much as the destination.

Motivation

When I declare structs in Rust, I have a habit of starting with tuples, and later refactoring my code to use structs. It feels quite natural to me, especially when I write enum variants with only one associated value. Refactoring these tuples into structs required changing parentheses into curly braces, and I've always found this to be a little annoying.

In an early version of Ribbon, I was brainstorming tuples and structs. I thought about how if structs used parentheses, this refactoring would be a lot smoother. I also thought about how a struct literal instantiation would look. For reference, Rust's syntax uses curly braces for struct literals, such as Struct { field1: value1, field2: value2 }. However, these initializer literals are just like function calls. In fact, the entire parameter tuple of a function is nothing more than a struct!

Separately, I was trying to design destructuring in Ribbon. Pattern matching and unwrapping objects is very handy, and just like Rust and many other languages, Ribbon would fully support it. My first implementation had an interesting quirk: Type hints were allowed in fields for tuples, as well as for the whole tuple. So, for example, all three lines here were equivalent:

let (x, y) = (32, "Hello")

let (x: Int, y: String) = (32, "Hello")

let (x, y): (Int, String) = (32, "Hello")

I actually quite liked the second form, as it reminded me of declaring a parameter tuple for a function. I've studied quite a lot of programming languages, and it's quite a rare syntax; I can only think of Scala which has syntax like it. Some languages to allow similar syntax for pattern matching or function calls, but not for general destructuring. And then it all clicked.

Function calls use destructuring!

This alone really helped me resolve a lot of design decisions. I have since refined and collated everything together. I'm very pleased to be able present it to you!

We'll begin with a simple function.

let f(x: Int, y: String) = {
    print("x: \(x), y: \(y)")
}

f(32, "Hello")

We can rewrite this function call into an equivalent Ribbon program:

{
    let (x: Int, y: String) = (32, "Hello")

    print("x: \(x), y: \(y)")
}

This means that the mechanism for mapping function arguments to parameters is the same mechanism for tuple destructuring! Realizations that simplify and consolidate logic like this are always so satisfying to find! But it gets better.

If we take inspiration from languages which allow named function arguments, we realize that there is precedence for structs to use parentheses, just like I dreamed of! Most languages use the assignment operator (=) for this purpose, but I found the colon (:) to work just as well.

let f(x: Int, y: String) = { ... }

f(x: 32, y: "Hello")

This implies a destructuring syntax of:

let (x: Int, y: String) = (x: 32, y: "Hello")

Now, there's just one problem, and some of you might see it already. Pretty much all languages that perform struct destructuring have syntax similar to the above, except the identifiers Int and String would actually be the new names for the fields. For example, Swift does actually support similar syntax to the above:

let (x: a, y: b) = (x: 32, y: "Hello")

This declaration creates two new variables, a and b, that are simply renamed from the x and y fields respectively. There's no way to insert type hints—they must be listed after, such as (x: a, y: b): (x: Int, y: String).

This stumped me for a little while. There's an ambiguity of using : to separate a variable and its type, versus using : to separate a field identifier and its value. Did I need to introduce a new operator? Did I need to resort to restricting types to have capitalized names and values to use lowercase ones?

Eventually, upon thinking of how function calls act just like destructuring, I realized I know of a language that supported renaming within function calls: Swift again!

For those unaware, Swift has two identifiers for every function parameter. One that is seen externally by callers as an argument name, and one that is used internally within the body as a parameter name. It's actually really clever. Here's an example:

func send_greeting(to recipient: Person) {
    recipient.send_message("Hello!")
}

send_greeting(to: bob)

This inspires us to consider struct destructuring with renaming to look like this:

let (x a: Int, y b: String) = (x: 32, y: "Hello") // This declares `a` and `b`!

I don't actually like the space separating x and a, so I did one final tweak. I added a rename operator: \. (I think a slash is used in some type theory notations for identifier substitution, and the numerator side is usually the target name, so it's perfect!)

let (x\a: Int, y\b: String) = (x: 32, y: "Hello")

To recap, that means Ribbon unifies structs, tuples, and function parameters.

I'll make this concrete with a few samples to show how flexible this really is. Note that all of the following assignment targets are structs!

// Tuple literals implictly cast to structs!
let (x: Int, y: Int) = (x: 3, y: 4)
let (x: Int, y: Int) = (3, 4) // Implicit casting to the above

// Structs support reordering
let (x: Int, y: Int) = (y: 4, x: 3)

// Type hints can also be specified after the struct
let (x, y): (x: Int, y: Int) = (3, 4)
let (x, y): (   Int,    Int) = (3, 4) // Equivalent to the above

// ... or omitted entirely to be completely inferred!
let (x, y) = (3, 4)

// Of course, we can also use previously declared struct or tuple types
let (x, y): T =  (3, 4)
let (x, y)    = T(3, 4) // This uses T's constructor


// Plus we have renaming for all the the above!
let (x\a: Int, y\b: Int) = (x: 3, y: 4)

let (x\a, y\b): (x: Int, y: Int) = (x: 3, y: 4)

let (x\a, y\b): T =  (x: 3, y: 4)
let (x\a, y\b)    = T(x: 3, y: 4)

let (x\a, y\b) = (x: 3, y: 4)

I currently only allow tuple literals to implicitly cast into structs, i.e. an unnamed field from a tuple literal into a named field of a struct. The reverse hasn't been implemented yet, though I am still brainstorming it.

Afterword

Last week, I said I'd be showcase enums today as well. I'm sorry, you'll have to wait later for that. I didn't realize I had so much to say about structs and tuples. I didn't even explain how structs and tuples are actually completely unified in Ribbon. Traditionally, a struct has all of its fields labeled, whereas a tuple has all of its fields unlabeled. Ribbon supports both, as well as the in-between where some fields are labeled and some aren't, all in the same representation. It acts similar to Python which supports labeled and unlabeled function arguments. I'll have to describe its implementation more thoroughly sometime, or perhaps I'll save it for when I get around to writing proper documentation.

I also didn't mention how structs and tuples support optional fields! It corresponds exactly to function definitions with optional parameters! I'll have to write a blog post just for functions later, but here's a teaser. (Note, null is an error value, so a field accepting null necessarily tolerates errors. There will probably be caveats as this is still actively being explored and worked on.)

let f(
    a: Int      // Required, so omitting it or providing `null` is not valid
    b: Int ?= 2 // Omitting or providing `null` for this field defaults to 2
    c: Int?     // Omitting this field defaults to `null`
    d ?= 2      // Just like `b`, though its type is inferred by usage
    e?          // Just like `c`, though its non-error type is also inferred
) = { ... }

Anyways, I hope you enjoyed seeing my brainstorming process! I had a lot of fun writing this blog post, so next week I'll try doing something similar for variables, references, and what I'm calling scaffolding. Until then, take care!