It took me a week to put this together, but I think it could have big potential.
====== Problem Summary
--Roc has three different ways to handle multiple arguments
--Lambda with args
lambda : Num a, Num a, Num a -> Num a
lambda = \a, b, c -> a + b + c
--Tuple - Unnamed variables, accessed using index
tupleAccess: (Num a, Num a, Num a)* -> Num a
tupleAccess = \args -> args.0 + args.1 + args.2
tupleDestructured: (Num a, Num a, Num a)* -> Num a
tupleDestructured = \(a, b, c) -> a + b + c
--Record - Named variables, optional variables, accessed using property name
recordAccess : { a : Num a, b : Num a, c : Num a }* -> Num a
recordAccess = \obj -> obj.a + obj.b + obj.c
recordDestructured: { a: Num a, b: Num a, c: Num a }* -> Num a
recordDestructured = \{a, b, c} -> a + b + c
--These three examples are logically equivalent and return the same result,
-- but have different internal implementations.
-- Can we have a syntax that is both simple and meets these requirements:
-- optional arguments and accessing the values with simple destructuring
-- and using existing lambda syntax?
====== Eureka !!
--Below is the result of combining the functionality into a simplified syntax
-- that meets the functionality of value access and optional parameters.
--Lamda would just be a destructured record using the variable names, no syntax changes here
--When the function is called demonstrates how it would be used as a record
lambdaAsRecord : Num a, Num a, Num a -> Num a
lambdaAsRecord = \a, b, c -> a + b + c
--Best practice would be matching variable names to function arguments
variableNamesMatch =
a = 1
b = 2
c = 3
lambdaAsRecord a b c
--Non-matching variable names or literal values would require mapping
-- variable names when calling the function
differentVariableNames =
x = 1
y = 2
lambdaAsRecord a:x b:y c:3
--Optional arguments still supported and defaulted when "destructured" / variables are defined
recordWithOptional : Num a, Num a, ? Num a -> Num a
recordWithOptional = \a, b, c ? 0 -> a + b + c
--Examples where variable names match, or need to be mapped
callRecordWithOptional =
a = 1
b = 2
recordWithOptional a b
callRecordWithOptional =
recordWithOptional a:1 b:2 c:3
callRecordWithOptional =
recordWithOptional a:1 b:2
--No impacts to definition, only how it is called
isEven : Int -> Bool
isEven = \num -> num % 2 == 0
--Have to match or map variable name when calling single argument lambda as record
callIsEven = isEven num:4
callIsEven2 =
num = 4
isEven num
-- Variable names have to be explicit for nested records, but the object
nestedRecord : { a : Num a, b : Num a, c : Num a } -> Num
nestedRecord = \object -> object.a + object.b + object.c
--Don’t have to specify record property names until destructured or function is called.
Point a : { x: Frac a, y: Frac a, z ? Frac a }
distanceBetweenPoints : Point, Point -> Frac a
distanceBetweenPoints = \p1, p2 ->
{ x: x1, y: y1, z: z1 ? 0.0 } = point1
{ x: x2, y: y2, z: z2 ? 0.0 } = point2
power = 2.0
Num.sqrt x:( --Need to match Num.sqrt variable name
Num.pow base:(x2 - x1) power + --Need to match Num.pow variable names
Num.pow base:(y2 - y1) power +
Num.pow base:(z2 - z1) power
)
--List.range example
createRange =
List.range start:At 0 end:Before 10
createEvens =
List.range start:After 1 end:At 10 step:2
===== Conclusion
--If all function calls use record based access, function calls would be more expressive
--The compiler would expect variable names to match at compile time.
--Function call arguments that don't match the variable name definitions would throw a compile error.
--Order of variable names when a function is called would no longer matter
--Based on how both the function is defined and called, the compiler can probably optimize the execution
For my own part, I'll contemplate this over a few days. One minor thought: your last few examples may need to be parenthesized like:
List.range start:(At 0) end:(Before 10)
I think no parenthesis are required in this case because the arguments do not contain any function calls.
I do like it when an IDE shows me the argument names but I am concerned with verbosity and how different it is compared to other popular programming languages.
I get the desire for a unifying concept, but the current impl is clear for the majority I presume, and this proposal would make roc more verbose (i think unnecesarily). This would push Roc towards a "big idea" language, where you could point out how cool it is that every argument is a record. We would also loose piping|>. If you want you api arguments to be written out at the call site, you use a record, which is 2 more characters that the proposed syntax. This is good for List.range, where it would not be clear what the bouds are just by looking at the program. It is not good for all the functions though. Like I wouldn't want to write this:
myList = [1, 2, 3]
List.get list:myList index:3
It would also insentivise people to use shorter variable names, to make it less painful to call functions. I can easily imagine List.get becoming:
List.get = \l, i ->
This is fine for this small of a function, but the tendency is worrying.
Also, it is usually advantageous to not call your variable the way they are called inside of a function you use. Let's you focus on the business logic. Like if I want to count how may Hamsters I have in my cages (have so much you need an app for it), I prefere this code:
hamstersInCages = [5, 6, 3]
List.sum hamstersInCages
With named args, I would write this
hamstersInCages = [5, 6, 3]
List.sum list:hamstersInCages
except as a human being, I gravitate towards the lowest resistance, so I would write
list = [5, 6, 3]
List.sum list
We have lost the focus from the domain. This can be debated if it is a good thing or not. In higher lvl application code, I think it is bad. The next example if pretty wild, but (when things go out of hands, which they tend to do) the prev example could become this to explain why we need the list:
# Represents the number of hamsters inside of my cages
list = [5, 6, 3]
List.sum list
Idk if this is also part of the idea, or you glanced over it, but this snippet is invalid Roc:
distanceBetweenPoints = \p1, p2 ->
{ x: x1, y: y1, z: z1 ? 0.0 } = point1
{ x: x2, y: y2, z: z2 ? 0.0 } = point2
The tutorial states why this is not allowed:
Destructuring is the only way to implement a record with optional fields [...]
This means it's never possible to end up with an optional value that exists outside a record field. Optionality is a concept that exists only in record fields, and it's intended for the use case of config records like this. The ergonomics of destructuring mean this wouldn't be a good fit for data modeling, consider using a Result type instead.
If I want to be cheaky, I could write this, which isn't something I want to be able to do:
distanceBetweenPoints = \p1, p2 ->
# What's the value of myEvilVariable?
myEvilVariable = p1.z
{ x: x1, y: y1, z: z1 ? 0.0 } = point1
{ x: x2, y: y2, z: z2 ? 0.0 } = point2
This became long. I really don't mean to bash on your idea, since you have to same goal as me: Make Roc Great! Three ways to handle multiple args seems too much indeed. In practice though, tuple arguments are really rare I think. The std doesn't have that many and I find myself almost always refactoring them to be a record, making Roc basically have 2 types of args simultaneously : positional and named.
While there is merit to this idea, I don't think the problem statement is accurate.
Roc has a single way to handle function arguments. There are simply multiple types that can be passed into a function.
That said, there is a caveat here.
Roc does allow for both building and destructuring an argument directly when calling the function, but that is not a whole new way to handle arguments.
As more concrete example:
-- This is how a tuple is meant to be used.
-- Not some special argument syntax, just a logical grouping.
Point a : (Num a, Num a, Num a)
tupleDestructured : Point a -> Num a
tupleDestructured = \(x, y, z) -> x + y + z
-- This is how a record is generally used.
Person : { name: Str, age: U8 }
recordAccess: Person -> U8
recordAccess = \person -> person.age
That said, there are two specific holes in Roc's calling syntax:
All this said, I think an opt-in syntax like this could potentially be useful. Then we can stop band-aiding over missing features with records.
Key words here are opt in though. I don't think we should take away standard positional arguments with any arbitrary name. @Norbert Hajagos comment goes over this quite well.
Yes, "default valued record fields" would be a better name. I am going to make a new topic for that (to me seems small change, but maybe others would have longer comments on it).
I haven't felt the hole with the current system. Tho when I create a tail recursive function, it would be great not having to include a record, just so that I can have the accumulator be initialized to a default value. Because of that, I often create a myRecFunctionHelp with an accumulator parameter and pass the default value from myRecFunction.
About records as named arguments having overhead: I know it is easy to say "the compiler will optimize that", but... couldn't it do that? (I don't quite understand what the overhead is in the first place tho). If the parameters are destructured inside the function definition, we know only the individual fields will be used and there is no need for a record. I guess the hard part is at the call site. But those calls that have an anonymous record constructed only to be immediately passed into the function could be transformed. I suspet those will be the most common use cases for functions like List.range. So functions like this:
List.range l {start: At 0, end: Before 10}
But not these
bounds = getBounds ...
List.range l bounds
You're right, I missed the fact that tuples and records are not multiple arguments, but are a single type pass as an argument to functions. While reviewing these replies, I realized I need to shift focus on how defaults work. I'll post a new thread on that subject.
Last updated: Jun 16 2026 at 16:19 UTC