In the conversation on List.range, we decided to merge multiple range-related proposals List.rangeInclusive, List.rangeExclusive, List.rangeBy into one function with a rich input that captures all of that nuance. What if we explored merging other builtin functions in a similar way?
For example, we currently have
List.sortAsc : List (Num a) -> List (Num a)
List.sortDesc : List (Num a) -> List (Num a)
but why not
List.sort : List (Num a), [Asc, Desc] -> List (Num a)
From
Str.replaceEach : Str, Str, Str -> Result Str [NotFound]*
Str.replaceFirst : Str, Str, Str -> Result Str [NotFound]*
Str.replaceLast : Str, Str, Str -> Result Str [NotFound]*
to
Str.replace : Str, [Each, First, Last], Str, Str -> Result Str [NotFound]*
I see a lot of sets of related builtins like Hash.addBytes/addI8/addU16/add* or Num.toU8/toU8Checked/to* that seem like strong contenders for this, but their return types are inherently different (either containing a concrete numeric type or sometimes wrapping the return type in a Result for fallibility). Therefore, I think the criteria here should be "builtin functions with related functionality, similar names, and identical return types should be merged by adding a tag union input parameter".
Thoughts? Does this look very elegant to anyone else?
From
[1, 5, 2, 4, 3] |> List.sortAsc
"1 5 2 4 3" |> Str.replaceEach " " ", "
to
[1, 5, 2, 4, 3] |> List.sort Asc
"1 5 2 4 3" |> Str.replace Each " " ", "
(in these examples, the only syntactical cost to app developers is the addition of a single space character, but I'm optimistic about the conceptual benefits of self-documenting alternatives and the flexibility of toggling behavior with a tag variable instead of switching function calls)
It does noticeably complicate type signatures...
Lots of new roc programmers will not be familiar with anything like our tags. I think this will add a noticeable difficulty to getting started with roc.
yeah my default preference is to have more, simpler functions
I think List.range is unusual in that it is notoriously easy to accidentally misuse
so I think in its case, there's a benefit to having one function with arguments that make the subtly different alternatives clear
which is not a problem most functions have
incidentally this preference is part of why Roc doesn't have default arguments
languages with default arguments typically would let you do either Str.join strings or Str.join strings ", "
with the second argument being optional and defaulting to either "" or " "
but I'd rather have Str.join and Str.joinWith that each do one thing
instead of having the more complicated function being the only thing you can reach for, with the simpler Str.join : List Str -> Str being unavailable
I like being able to reach for something with the simplest type that will get the job done for me, and only looking further for something more complicated when the simplest thing won't meet my needs (which it often will)
it also facilitates a nicer documentation reading experience: I can learn about the simplest operation by reading docs that are scoped narrowly too, and then discover the more complicated ones by links like "To insert a string in between each of the joined strings, use Str.joinWith"
I think it is also very important to note that tags can have a costs. For example, if List.range doesn't get inlined, you will be spending extra time twiddling with memory on every function call to create the tags. Then you will be spending extra time in the function matching on the tag and pulling the data back out. As such, i would be careful about doing this conversion to any complex functions. Otherwise we would be accidentally slowing down every function we do this too.
to be fair, I think it's safe to assume it'll get inlined
(if it doesn't automatically, we can make sure it does because it's a builtin)
For List.range sure, it is small. For other builtins that are larger, it may not be reasonable to inline them.
Last updated: Jun 16 2026 at 16:19 UTC