Breaking this off from #ideas > static dispatch , cuz that thread ended up focusing on the syntax to call functions and I don't want to derail that conversation with a topic change.
There are lots of good reasons for this change, and I think there are good ideas in this direction, but making the function you're calling depend on the type opens up a whole suite of new problems:
The reason we don't allow import * is so that from a file, you always know what a function name resolves to just from within one file. And relatedly, a change in another file (like adding another export) won't surprise change which function you're calling. If which function you call depends on the type, do we lose that guarantee?
- This is partially for the compiler (does it need to know if function names resolve before it knows the types?), but it's also for humans who are reading the code / compiling it in their brain. If I see .appendIfOk(), can I figure out where to look for the documentation?
I think we kind of glossed over defining "the module where the type is defined". That's obvious for all the builtins, but what if a module has a type whose name doesn't match the module name? What if a module has multiple types, can they both have methods in the same file? What if a module has multiple nominal types? What if multiple files define the same structural type, which module do you get methods from? Does static dispatch only work for nominal, not structural types?
And some thoughts on how this relates to abilities:
(A caveat: that I've never really understood abilities, (because they're complicated and it'd be cool to remove them!) but I may have some misunderstandings here.)
Sometimes methods aren't the right way to think about abilities even if the first argument is the right type. As an example, a.eq(b) implies an asymmetry between a and b. Like this is a's method. In other languages, when I call methods like this, I always have to stop and think if I'm calling them in the right order or calling the method on the right object. With a == b or eq(a, b), I don't have the same issue, because a and b are on equal footing.
- concat is another example of this. List.concat([list_a, list_b, list_c]) more accurately indicates the symmetry than list_a.concat([list_b, list_c]).
If an ability requires multiple functions, there's no longer any way to link those together as related.
This proposal removes the need to say a type implements an ability, which is nice, but it also removes saying that an ability exists. Like, the existence of theEncoding ability tells me that equality is something many types have, and maybe my custom type should also have it. Is there now an ability for every possible function? Like, as an arbitrary example, List has an appendIfOk function. That's not something anybody would ever write an ability for, but is there now a method for it, that I should consider adding to my type? Abilities say that Eq and Encoding are broad and important concepts, and that should be indicated somewhere (in an ability definition), not just in a bunch of coincidentally identically named functions.
- This is also related to the "operator overloading is too easy" problem: You can accidentally implement a method/ability without explicitly intending to, even if your function is just coincidentally named the same and doesn't really fulfill the ability's semantics.
- I phrase having ability definitions as a good thing, but it might be the exact same thing as the "classification trap", which can also be framed as a bad thing.
The aliases for where constraints is just redefining abilities again. It seems good to remove a chunk of the complexity of abilities, but the fact that there's a reason to then bring the definitions back means that maybe they shouldn't be removed in the first place? Like, abilities still exist, they're just implicit/structural rather than explicit/nominal, and I think for abilities, explicit/nominal makes more sense.
I do think abilities are complex and could use a redesign or replacement, and it is nice that static dispatch could replace them, but it's not a perfect replacement.
Other
- reqHeader.value |> Str.fromUtf8 |> Result.try became Str.fromUtf8(reqHeader.value).try(. Str.fromUtf8 isn't a method (because it creates a string rather than consumes one, which took a moment to figure out). You could put it into the chain as a normal function call, but in this case didn't, because normal function calls have become harder than methods to do, so I expect this is something that would happen often, not just in the example. But this code is worse with Str.fromUtf8 outside of the pipeline, because now the data flows first left and then right. In the pipeline, where methods and functions are called the same, the data flows cleanly in one direction, all to the right.
- .try(Str.toI64) and .try(.toI64()) do the same thing, and now you have to decide which one to use. In the doc, it's Str.toI64, because it's not longer and it's nice to show which module's function is being called. But this proposal is about adding the worse one. So now this new syntax has created a worse way to do something we could already do, which is bad.
Things I like about this proposal:
. becomes a sort of generic "do the next thing" operator, whether that thing is field access, method call, or function call.So how can we get those benefits without the problems related to dispatching? I think we should explore ideas that still require specifying module names, don't do the static dispatch part of the proposal, but still focus on the new . chaining that will be familiar to people who have used methods in other languages. Like, I expect that we can still get some nice auto-complete when you type . based on looking for functions that take this type as a first argument, even if we don't use that type information for dispatching after the code is written.
Not to make this topic about syntax too, but I'm curious to hear your input over in the syntax thread on Jasper's latest syntax counter-proposal: https://roc.zulipchat.com/#narrow/channel/304641-ideas/topic/static.20dispatch/near/481566444
And relatedly, a change in another file (like adding another export) won't surprise change which function you're calling. If which function you call depends on the type, do we lose that guarantee?
We lose a little, but I don't think it is meaningful. If a method changes it's return type, it could change a chain of method calls
But I don't think anything fundamental is lost. For any method, go to the module the nominal type is defined in and you will find the definition.
Adding an export will never change anything, but changing a return type requires less changes elsewhere.
next = x.get_map().insert(k, v)
will just work even if the type of .get_map is changed to return a differnt type of dict from the standard library version. So it is arguably less brittle. But it also is less explicit
what if a module has a type whose name doesn't match the module name?
Doesn't matter. Can be named anything
What if a module has multiple types, can they both have methods in the same file? What if a module has multiple nominal types?
Yes. All can have methods. The only issue is that we currently don't have any form of name spacing besides modules. So if you want to define two different types both with .append(), they need to be in different modules with the current proposal
What if multiple files define the same structural type, which module do you get methods from? Does static dispatch only work for nominal, not structural types?
Only nominal types get methods to avoid issues of being unclear which function to call.
Are there any ability functions that don't? Maybe something like a 0-arity new/empty function, that creates and returns the type but doesn't take it as a param? Are there any abilities like that?
There are abilities like that. So static dispatch does not fix everything. That said, for those abilities you have to manually specify the type (or use the value in a way that uniquely defines the type). So those are already brittle. Given these constraints, it may be reasonable to remove those kinds of abilities and enforce explicitness in the code. This is already hit with Inspect. It is too flexible. As such, I prefer SpecificInspector.empty over requiring specifying the type.
So I would argue that 0-arity ability functions cause issues today and qualifying works better. So this is a fine change.
I guess Decode is maybe the best-known example of an ability that returns rather than takes the type as an argument.
Yeah, returning an unknown type is kinda ok. The issues is when it is used in a way that they type is never clear. This is really easy to hit with constructors of opaque types. It is much harder to hit with decode
For example, if you create a opaque type via ability, only use the opaque type with the ability, and eventually return something separate from the type (like a string or hash or list of bytes). They type is literally never specified at all. Just some unknown type that has the ability
This can be hit with encode style abilities and I think it just crashes currently. So I think forcing explicit constructors via qualified names is important to avoid this edge case where type checking breaks without annotations
I think that makes sense to me in terms of abilities, but not seeing how it would work with static dispatch.
Let's for argument's sake simplify the type of decode to List U8 -> a where a implements Decode.
Suppose I have my bytes : List U8 and press ., wouldn't static dispatch start looking for methods in the List module, rather than think of decode?
you just wouldn't get any auto complete
The type is a
a has no know methods
some someList.decode(). would not generate any auto complete suggestions.
That said, I don't think there will be a list.decode() method. But it would still be hit in the same way with Json.decode(someList).
The big difference here is that when you decode you have a totally unknown value that you must use. The use will tell us what the value is. In the ability case you instead have a a where a implements MyAbility. For that cases, as long as I only call functions from MyAbility, a will never gain any information whatsoever.
Technically you could just never use the an a and it wouldn't gain type info either, but almost any use of a raw a will clarify what the type is. So it is a lot less of an issue.
Sometimes methods aren't the right way to think about abilities even if the first argument is the right type.
This I 100% agree with. Equality should be symmetrical. In roc's case where equality is only between the same type, this isn't an issue. It will still be symmetrical in definition. It is always eq : a, a -> Bool. You can write method for it like this. eq = \lhs, rhs ->. The real problems from eq specifically arise when you enable comparing multiple different types (generally due to subclassing). That said, I'm sure there are cases where the asymmetry will create problems. The problems will be no worse than having MyList.compareToSet mylist set, but they will exist and will be harder to spot than mylist.cmp(someSet)
Totally a trade off that all other languages face. Less prominent in roc, but it still exists today due to optimizing for |>. It introduces the same class of problem. I might want to be able to do list1 |> List.concat [list2, list3] which is asymmetrical.
I definitely think this proposal makes it worse, but I do think it is a class of problems that exist today and are in most languages
If an ability requires multiple functions, there's no longer any way to link those together as related.
Yeah, I think we will really want some sort of interface. The only way to link them together would be to make a single higher order function as the interface and implement it on multiple type (as in the reader function returns a record of functions needed to match the reader interface; Any type that implements the reader method with the exact same types would follow the interface), but that would have terrible ux.
I do think that we would still want some sort of interface or trait. That said, it might just be an "implements alias". Very roughly
BufReader: implements read, read_buffered, ...
a where a implement BufReader
an "implements alias" would fit well with the structural-typed-by-default enums and structs
This proposal removes the need to say a type implements an ability, which is nice, but it also removes saying that an ability exists.
As mention in the message above, some sort of "implements alias" or interface or trace could still be used to specify the existance. I think those likely would be added, but they have not been scope and need more thought.
Is there now an ability for every possible function?
This does feel very go interface like. You can define a grouping, but everything is automatically opted in. If you happen have methods with the right types and names, you are automatically opted into the interface. Personally, I perfer to make this explicit. Also, for our automatic ability deriving, this probably should stay explicit.
you can accidentally implement a method/ability without explicitly intending to, even if your function is just coincidentally named the same and doesn't really fulfill the ability's semantics.
I feel like this generally isn't a problem in practice. In the myriad of languages with dot method calls, I think it is an exceptional rare bug to implement a method with the exact same name and types, such that is sneaks into code without breaking things. Generally, even if name and types are the same, the functionality will be different enough that the bug is caught very quickly if the actually implementation is different from the expeceted.
Definitely increases the chances of changing type without thinking about the consequences, but it is not significantly different from typing SomeList.append and assuming it works the same as List.append
The aliases for
whereconstraints is just redefining abilities again. It seems good to remove a chunk of the complexity of abilities, but the fact that there's a reason to then bring the definitions back means that maybe they shouldn't be removed in the first place? Like, abilities still exist, they're just implicit/structural rather than explicit/nominal, and I think for abilities, explicit/nominal makes more sense.
Yeah. I could go either way here. Go seems to just work for the most part and it is implicit. I am much more used to explicit. To me, the most important part is that they can be clearly defined and typed. "implements aliases" would be enough that I feel ok... but I'm not sure if it is better than keeping things explicit...
I think having two different ways to call methods vs normal functions adds another micro-decision to make.
+1
Thanks for all the insightful things to think through @Sky Rose. Hopefully some of my answers are helpful. I definitely game some speculative answers that are making assumptions of how things will evolve (like "implements aliases"). Hoping those help fill out the picture more clearly. But they also may miss the mark. So take them with a bit of skepticism. They are more the most direct (and I think likely) solution more so than what roc will definitely do if we add static dispatch.
Thanks for the great responses Brendan.
Good point about 0-arity abilities being broken. This shows up in OOP constructors a lot. E.g when you make a Map, you need to specify what kind of map, so you do new HashMap, but then go back to calling methods that are part of the general Map interface.
In general, abilities are more confusing than I realized. This makes me more enthusiastic about removing them, but I don't think it means we should compromise and accept similar problems with static dispatch. Unless there's some sort of unavoidable limitation or tradeoff that we have to make (there might be), we should keep looking for a design that solves all of our ability-related problems instead of just shuffling them around.
I think this implicit vs explicit abilities design is something that we should explicitly decide, and then choose language constructs that support that. I also think that explicit ability definitions would be better. If we decide we want implicit abilities like Go, that's fine, but we should choose that because we want implicit abilities, and not because they happened to fall out of the way we call methods.
For the symmetry of Eq, I'm not thinking about whether the two arguments are the same type, I'm thinking about the thought process of writing a.eq(b) vs eq(a, b). This is a problem regardless of whether we enable different types or not.
You make a good point that the asymmetry pops up in pipelines whether or not we do methods. I sometimes write concat(a, b) |> sort() or whatever instead of a |> concat(b) |> sort() to avoid this, even though the pipeline version works.
when you make a
Map, you need to specify what kind of map, so you donew HashMap, but then go back to calling methods that are part of the generalMapinterface.
Yeah, for decidable type inference that doesn't have weird edge cases, I think we need to require the equivalent of new HashMap. So either pass in an empty map or pass in a constructor function for your specific map variant.
but we should choose that because we want implicit abilities, not because they happened to fall out of the way we call methods.
+1
Though there is also all of the method name and type false sharing that will happen if we have unqualified method calls in general. 2 types both with the same append: Self, a -> Self method. That is a reality of having methods. And is someone writes this function, we have to compile it to something:
doStuff = \x ->
x.append(7)
This requires that we have some form of essentially implicit abilities if we allow for non-qualified method names. Cause x will be a a where a implements (append: a, Num b -> a)
So there are 4 options:
I'm thinking about the thought process of writing
a.eq(b)vseq(a, b)
Makes sense. To me, this feel like an issue that already exists so tagential, but the change definitely would ingrain it deeper into the language. I don't have much of an opinion. As long as a.eq(b), eq(a, b), b.eq(a), a == b, and b == a all call the exact same function, I think it is simply a bug on the function if symmetry is broken. So I don't mind it.
I haven't caught up reading all of this yet (sick kid, will be less available today than normal) but I wanted to note that asymmetry has been requested in the specific context of operator overloading with matrices and non-matrices
where you want to use an arithmetic operator on a matrix and have the other side be something that isn't a matrix
regarding knowing types - something I realized the doc doesn't address is how this would work:
user = Decode.decode(bytes, Json.utf8)?
this is an example of wanting to do static dispatch, but where the subject type of the dispatch is in the return value of the function rather than its first argument
definitely possible for the type checker to do, and I think decoding is a valuable use case that abilities offer that is worth bringing over to static dispatch, but I hadn't thought about that scenario, what the syntax would look like, etc.
I have some vague directional ideas but need to tinker with them a bit :big_smile:
Last updated: Jun 16 2026 at 16:19 UTC