Parameters and binding forms should be mutually recursive
by Sam Estep, 2025-09-05
Some programming languages let you define functions that are easy to call with more structure than a flat argument list, whether via keyword arguments, tuples, records, or some nesting of those. But I don't know any language that makes it very nice to define such functions:
- I want to destructure the input to bind pieces of it to local values, without having to come up with extra names for intermediate values.
- I want to describe the shape of the input in the function signature itself, without duplicating structure and names just to write out the types.
Here's an example of my proposed syntax:
fn transport(
(start: Timestamp, end: Timestamp),
foo: Foo,
{
bar: Bar,
person = { name = who: String, phone: String },
options = { fragile: Boolean, window: HalfDay },
},
) {
do_stuff(who, foo);
more_stuff(phone, end, window);
other_things(start, fragile, bar);
}
let bar = something();
transport(
(iso("2025-09-05T09:00:00Z"), iso("2025-09-05T11:00:00Z")),
foo123,
{
bar,
person = { name = "Alex Smith", phone = "+1 (555) 555-5555" },
options = { fragile = true, window = HalfDay.PM },
},
)
In this snippet, we're defining a function named
transport
which takes three parameters (or if you
prefer, you can think of it as taking a tuple with three elements).
-
The first parameter is a pair of
Timestamp
s, which we destructure into locals namedstart
andend
; the pair itself doesn't get bound to any name. -
The second parameter is just a
Foo
. We bind it to the namefoo
. -
The third parameter doesn't get bound to a name. It's a record
with three fields:
-
The
bar
field has typeBar
. It gets bound to the namebar
. -
The
person
field doesn't get bound to any name. It is a record with two fields:-
name
is aString
, and we bind it to a local namedwho
. -
phone
is also aString
, and we bind it to a local namedphone
.
-
-
Similarly the
options
field is a record with two fields; we bind each to a local with the same name.
-
The
In the body of transport
, we use all those locals to
call other functions.
Then somewhere else, we call transport
.
- For the first parameter we just construct a tuple.
-
For the second parameter we pass in a
Foo
namedfoo123
that we already had. -
For the third parameter we use nested record literals:
-
We already have a value named
bar
, so we can write justbar
instead ofbar = bar
. -
For
person
andoptions
, we just use more record literals.
-
We already have a value named
The point is to make it almost as convenient to define and call a function with structured input (nested records and tuples) as it is with flat positional parameters; you just need to add a couple punctuation marks denoting the structure itself, and that's it.
Comparisons
Let's compare this pseudocode to what we're forced to write in the three languages I'm most familiar with. Obviously I don't know every programming language, so if there exists one that does better than these three and checks all the boxes here (or if there's a better way to do it in one of these three languages), let me know!
Python
The current most popular programming language does an alright job on this.
from datetime import datetime
from typing import TypedDict
class Person(TypedDict):
name: str
phone: str
class Options(TypedDict):
fragile: str
window: HalfDay
def transport(
times: tuple[datetime, datetime],
foo: Foo,
/,
*,
bar: Bar,
person: Person,
options: Options,
):
start, end = times
def inner(*, name, phone, fragile, window):
who = name
do_stuff(who, foo)
more_stuff(phone, end, window)
other_things(start, fragile, bar)
inner(**person, **options)
bar = something()
transport(
(iso("2025-09-05T09:00:00Z"), iso("2025-09-05T11:00:00Z")),
foo123,
bar=bar,
person={"name": "Alex Smith", "phone": "+1 (555) 555-5555"},
options={"fragile": True, "window": HalfDay.PM},
)
Python does let you pass function parameters either positionally or
by name, without you having to do anything special. In this example
I was a bit extra, using /, *,
to
force everything before to be positional and everything
after to be named.
Still, this Python code has some issues:
-
The most minor one is that we can't destructure the
tuple
right away, so we need to make a variable name for it. - There's no shorthand syntax to avoid writing a parameter name twice if your variable name is the same.
-
While keyword arguments are great, they only really work for the
outermost level of arguments to a function. This means we have to
make a couple concessions:
-
We have to make names for the
Person
andOptions
types, and define their field types a bit far from their corresponding parameters in thetransport
function. -
I'm not aware of a good way to destructure a dict unless you
want to do something cursed like
pip install dict-unpacking-at-home
. The best I'm aware of is this trick where you use unpacking and keyword arguments to achieve a similar result with aninner
function. Of course, you'd have to repeat this trick for each additional layer of nesting. - We can't use a variable name different from the field name when destructuring.
-
We have to make names for the
TypeScript
This one is definitely the closest to the pseudocode I wrote at the top.
function transport(
[start, end]: [Timestamp, Timestamp],
foo: Foo,
{
bar,
person: { name: who, phone },
options: { fragile, window },
}: {
bar: Bar;
person: { name: string; phone: string };
options: { fragile: boolean; window: HalfDay };
},
) {
doStuff(who, foo);
moreStuff(phone, end, window);
otherThings(start, fragile, bar);
}
let bar = something();
transport([iso("2025-09-05T09:00:00Z"), iso("2025-09-05T11:00:00Z")], foo123, {
bar,
person: { name: "Alex Smith", phone: "+1 (555) 555-5555" },
options: { fragile: true, window: HalfDay.PM },
});
The only issue here is locality. Rather than being able to write the types for the fields and tuple elements next to the names we use for destructuring, we have to write them separately and duplicate all the field names.
Rust
This one is the worst of the three in my opinion, which is sad because Rust is currently my favorite programming language.
use std::time::Instant;
struct Person<'a> {
name: &'a str,
phone: &'a str,
}
struct Options {
fragile: bool,
window: HalfDay,
}
struct Args {
bar: Bar,
person: Person,
options: Options,
}
fn transport(
(start, end): (Instant, Instant),
foo: Foo,
Args {
bar,
person: Person { name: who, phone },
options: Options { fragile, window },
}: Args,
) {
do_stuff(who, foo);
more_stuff(phone, end, window);
other_things(start, fragile, bar);
}
fn main() {
let bar = something();
transport(
(iso("2025-09-05T09:00:00Z"), iso("2025-09-05T11:00:00Z")),
foo123,
Args {
bar,
person: Person {
name: "Alex Smith",
phone: "+1 (555) 555-5555",
},
options: Options {
fragile: true,
window: HalfDay::PM,
},
},
)
}
Rust doesn't have either keyword arguments as a first-class concept like Python or anonymous struct types like TypeScript. And unlike other systems languages like C++ or Zig, you can't omit the type name in a struct literal at the call site, so calls to functions using an approach like this in Rust are just too verbose.
Because passing named arguments like this in Rust is so
inconvenient, it's very common to just use positional arguments
instead. The Rust culture of using nice specific types can help
prevent accidentally mixing up arguments if all a function's
argument types are different, but if some of them are the same type,
it's just error-prone. For instance, in the
wasm-encoder
crate, the
old Instruction
API for memory.copy
names the src_mem
and dst_mem
fields so
you can easily see which is which at the call site; but the
new InstructionSink
API
just uses positional arguments so you'd have to hover over the
memory_copy
memory name to see which is which. (Full
disclosure:
I wrote the new API
and this was a point of discussion before it got merged.)
Yes, I know that rust-analyzer shows parameter names as inlay hints by default. No, I do not have inlay hints enabled. In my opinion, they make code harder to read by causing things to visually spill past the rustfmt column limit.
Representation
OK, so how might one implement this? I'll leave an actual parser as an exercise for the reader (although if you're curious, here's a link to one I prototyped in the past), but let's at least look at how one might represent it at the AST level.
Standard
Using TypeScript type definitions, here is how we might represent a simplified AST for a subset of legal TypeScript function signatures:
type TypeField = {
name: string;
type: Type;
};
type Type =
| { kind: "name"; name: string }
| { kind: "tuple"; elements: Type[] }
| { kind: "record"; fields: TypeField[] };
type BindingField = {
name: string;
binding?: Binding;
};
type Binding =
| { kind: "name"; name: string }
| { kind: "tuple"; elements: Binding[] }
| { kind: "record"; fields: BindingField[] };
type Parameter = {
binding: Binding;
type: Type;
};
type FunctionSignature = {
parameters: Parameter[];
result?: Type;
};
As you can see, Type
and Binding
are both
recursive types, but they're completely separate from each other.
Then the Parameter
type bundles together one of each.
Expand this to see what the earlier TypeScript example would look like using those AST types.
{
parameters: [
{
binding: {
kind: "tuple",
elements: [
{ kind: "name", name: "start" },
{ kind: "name", name: "end" },
],
},
type: {
kind: "tuple",
elements: [
{ kind: "name", name: "Timestamp" },
{ kind: "name", name: "Timestamp" },
],
},
},
{
binding: { kind: "name", name: "foo" },
type: { kind: "name", name: "Foo" },
},
{
binding: {
kind: "record",
fields: [
{ name: "bar" },
{
name: "person",
binding: {
kind: "record",
fields: [
{ name: "name", binding: { kind: "name", name: "who" } },
{ name: "phone" },
],
},
},
{
name: "options",
binding: {
kind: "record",
fields: [{ name: "fragile" }, { name: "window" }],
},
},
],
},
type: {
kind: "record",
fields: [
{ name: "bar", type: { kind: "name", name: "Bar" } },
{
name: "person",
type: {
kind: "record",
fields: [
{ name: "name", type: { kind: "name", name: "string" } },
{ name: "phone", type: { kind: "name", name: "string" } },
],
},
},
{
name: "options",
type: {
kind: "record",
fields: [
{ name: "fragile", type: { kind: "name", name: "Boolean" } },
{ name: "window", type: { kind: "name", name: "HalfDay" } },
],
},
},
],
},
},
],
}
Proposal
Here's how I'd instead represent parameters and binding forms in the AST:
type TypeField = {
name: string;
type: Type;
};
type Type =
| { kind: "name"; name: string }
| { kind: "tuple"; elements: Type[] }
| { kind: "record"; fields: TypeField[] };
type BindingField = {
name: string;
parameter: Parameter;
};
type Binding =
| { kind: "name"; name: string }
| { kind: "tuple"; elements: Parameter[] }
| { kind: "record"; fields: BindingField[] };
type Parameter = {
binding: Binding;
type?: Type;
};
type FunctionSignature = {
parameter: Parameter;
result?: Type;
};
We've kept Type
(and its helper TypeField
)
exactly the same. There are only three differences:
-
We've replaced
Binding
withParameter
in the"tuple"
and"record"
cases forBinding
, to allow types to be written intermingled with destructuring. -
We've made the
type
field optional onParameter
, to avoid forcing types to be duplicated. -
We've changed the
FunctionSignature
to just have oneParameter
instead of aParameter[]
list, because our ability to handle nesting means a parameter list is naturally handled like any other tuple.
That last one is probably a bit too cheeky, since it makes things
weird by having a function's parameter list be a tuple
unless that tuple would have only have one element and
there's no trailing comma. So in a real-world imperative language
you'd probably want to keep it as an explicit
Parameter[]
list. But in a functional language, you
might actually want to just use a single Parameter
; or
have a Parameter[]
list but use that for currying
instead of tuples, as is tradition.
Expand this to see what the original pseudocode example would look like using these alternative AST types.
{
parameter: {
binding: {
kind: "tuple",
elements: [
{
binding: {
kind: "tuple",
elements: [
{
binding: { kind: "name", name: "start" },
type: { kind: "name", name: "Timestamp" },
},
{
binding: { kind: "name", name: "end" },
type: { kind: "name", name: "Timestamp" },
},
],
},
},
{
binding: { kind: "name", name: "foo" },
type: { kind: "name", name: "Foo" },
},
{
binding: {
kind: "record",
fields: [
{
name: "bar",
parameter: {
binding: { kind: "name", name: "bar" },
type: { kind: "name", name: "Bar" },
},
},
{
name: "person",
parameter: {
binding: {
kind: "record",
fields: [
{
name: "name",
parameter: {
binding: { kind: "name", name: "who" },
type: { kind: "name", name: "String" },
},
},
{
name: "phone",
parameter: {
binding: { kind: "name", name: "phone" },
type: { kind: "name", name: "String" },
},
},
],
},
},
},
{
name: "options",
parameter: {
binding: {
kind: "record",
fields: [
{
name: "fragile",
parameter: {
binding: { kind: "name", name: "fragile" },
type: { kind: "name", name: "Boolean" },
},
},
{
name: "window",
parameter: {
binding: { kind: "name", name: "window" },
type: { kind: "name", name: "HalfDay" },
},
},
],
},
},
},
],
},
},
],
},
},
}
One quirk you can see here is that, for the common case where we
bind a record field to a local with the same name, we only write the
name once in the source program, but that name still ends up
duplicated in the AST. I don't think this is really an issue, but if
you want then you could avoid it by just inlining the definition of
Parameter
into the BindingField
type and
then making the binding
optional there. I just didn't
do that here because it'd make the AST types a bit less clean.
Checking
This does make typechecking a bit more complicated. For instance, what if someone writes code like this?
fn foo((x: A, y: B): (C, D)) {}
The typechecker
needs to unify
the type it infers from binding
with the explicitly
provided type
, if there is one. And if they don't
match, it needs to
report an error.
Also, languages like Rust enforce the property that every top-level
function declaration needs to provide a fully-typed signature. In my
prototype I implemented this by just
adding a strict: bool
flag
to the function for typechecking parameters; not sure if there's a
better way in general.
Conclusion
Some final notes just to clarify things a bit more:
-
While the example I showed at the top destructures everything and always colocates types with bindings, this syntax doesn't force you to do that. You can still keep types separate if you want, and/or replace
=
with:
to just destructure part and then use dot syntax to access specific fields later. Whichever you prefer.fn transport( (start, end): (Timestamp, Timestamp), foo: Foo, { bar: Bar, person: { name: String, phone: String }, options: { fragile: Boolean, window: HalfDay }, }, ) { do_stuff(person.name, foo); more_stuff(person.phone, end, options.window); other_things(start, options.fragile, bar); }
-
This syntax isn't limited to function parameters; I think it makes sense to just replace
Binding
withParameter
pretty much everywhere. So, instead of this:type Statement = | { kind: "expression"; expression: Expression } /* ... */ | { kind: "let"; binding: Binding; type?: Type; expression: Expression; };
You'd do this:
type Statement = | { kind: "expression"; expression: Expression } /* ... */ | { kind: "let"; parameter: Parameter; expression: Expression; };
And that lets you write
let
bindings in any of these styles:let (x, y): (Foo, Bar) = baz(); let (x, y) = baz(); let (x: Foo, y: Bar) = baz(); let x: Foo, y: Bar = baz(); let x, y = baz();
Thanks for reading!