Description
Overloaded functions in Extra // Extra
I have a proposal for overloaded functions in Extra:
fn double { -- brackets indicate overloaded functions
(# input: Int) => input * 2
(# input: String) => input <> input
}
The problem with this solution:
The type system can distinguish between these two, but I have (and want) a fence between the type system, which checks for correctness, and the runtime, which just runs the thing. I do not want the runtime to require the type system. Extra could, in theory, operate as an untyped scripting language, but that’s not the reason why I want this fence. I think it provides a good design aesthetic.
Consider:
let
value = … -- elided
in
double(value) -- will this call the `Int` or `String` function?
Admitedly, what I’m calling a shortcoming is also a benefit - if this code is meant to return a String | Int
, then this ambiguity is really useful,
because you can take a String | Int
as input and return a String | Int
without any further ado, but our implementation is still nicely separated into
two functions.
But I still don’t like it, and the reason becomes clearer when we take a more real-world use-case for overloaded functions: File I/O.
fn open {
(# read: String): FileReader => …
(# write: String): FileWriter => …
}
This is the use-case that prompted me to find a solution to overloaded functions. (a) I think that the return value should be different depending on whether you are reading or writing - a generic “file handle” that may fail if the mode was incorrect doesn’t help the developer. (b) This could be solved with having two function names… but I’m not creating a language called “Simple”.
Getting to the point, here is what I actually propose, which is really just an extension / subtle alternative to having two function names. I’ll call this the “function discriminant” option:
let
writer = open(.write, "filename1.txt")
reader = open(.read, "filename2.txt")
in
writer.write(reader.read())
-- the syntax for this function:
fn open {
(.read, # path: String): FileReader => …
(.write, # path: String): FileWriter => …
}
Those arguments in front are just to inform the compiler which function to use. As long as the required arguments are enough to distinguish at the call site, we allow it. Actually this specific example could just as easily use named arguments instead:
fn open {
(read: path: String): FileReader => …
(write: path: String): FileWriter => …
}
let
writer = open(write: "filename1.txt")
reader = open(read: "filename2.txt")
in
writer.write(reader.read())
I’ll leave it as an exercise to the programmer to decide which one is correct, the hill I’m willing to die on is that the shape of the function should disambiguate the function call, not the type system.
(Slow nod to Objective-C and Swift, which also take this approach. Rust is all
well and good, amazing, really, but I can’t get excited about into()
, it is too much magic for me.)
Published: 2025-08-12