One example I have seen advocating for the existence of open type unions is the await function:
Task.await : Task a err, (a -> Task b err) -> Task b err
where the returned err should encompass both the errors returned from the first and second Task. I wonder if it would be possible to instead use type union unions to achieve the desired behavior. It would look something like this:
Task.await : Task a errA, (a -> Task b errB) -> Task b (errA | errB)
indicating that a possible error could come from both Task A and B. The same would work the other way around for parameters:
lighten : [Black, Grey] -> [Grey, White]
lighten = \luminosity -> where luminosity is
Black -> Grey
Grey -> White
darken : [White, Grey] -> [Grey, Black]
darken = \luminosity -> where luminosity is
White -> Grey
Grey -> Black
changeLuminosity : [Grey], Bool -> [White, Black] # Grey is the only valid input
changeLuminosity = \color, lighten ->
if lighten
then lighten color
else darken color
Similar functionality would look like this in TS:
function returnsab(): 'a' | 'b' {
return true ? 'a' : 'b';
}
function returnsac(): 'a' | 'c' {
return true ? 'a' : 'c';
}
function returnsabc(): ReturnType<typeof returnsab> | ReturnType<typeof returnsac> {
return true ? returnsab() : returnsac();
}
function takesab(_: 'a' | 'b') {}
function takesac(_: 'a' | 'c') {}
function takesabc(
param: Parameters<typeof takesab>[0] & Parameters<typeof takesac>[0]
) {
return true ? takesab(param) : takesac(param);
}
where the typeof stuff could be replaced by their actual values.
so union types have an unfortunate property of defeating the performance optimization that makes Roc's type checker fast in practice
I actually thought about this early on, and one of the big pieces of advice I got from Evan (who made Elm) when I was talking to him about Roc in the early days was "if you do that, your type checking times will have an exponential slowdown that you can never fix"
there are some other downsides, for example in error message quality
e.g. instead of saying "this needed a List Str but here it's being given a Str" you end up with error messages like this needed a List Str but here it's being given a Str | List Str" because that's the inferred union type after one branch of a conditional returns List Str but the other returns Str`
I also suspect union types lead to more complicated API designs
e.g. instead of having a function take a Str, you have it take Str | List Str | Num * "as a convenience" because you can neatly convert all of those into a Str to save the caller from doing that
and this leads to APIs that end up with bloated types for the sake of more concise function calls, which I don't think is the right tradeoff, but which union types facilitate
there are similar tradeoffs around optional function arguments or multi-arity functions
all of which (union types, optional arguments, multi-arity functions) share an upside of allowing you to make more backwards-compatible additions to the same function, but then also a downside where making a backwards-compatible addition to the same function (instead of choosing a different name for the new function) means the function everyone uses - whether it's the old one or the new one - has a more complex type than it would if you made a new one and (if desired) deprecated the old one
so all of those things considered, I don't think it's worth it :big_smile:
to be fair though, something I would like is the ability to say something like File.writeUtf8 : Path | Str, Str -> Task …
it would be convenient to not have to call File.writeUtf8 (Path.fromStr "myfile.txt") … and instead be able to call File.writeUtf8 "myfile.txt" directly
and there are also some neat things like being able to return Str | Err Whatever instead of Result Str Whatever, so you can omit the Ok and so on
I hadn't even though about the overload aspect of this really :smile: . And an exponential slowdown by itself sounds like a deal-breaker.
I agree, although my knowledge of that may be incomplete! Ayaz definitely has a much more extensive knowledge of type-checking research than I do :big_smile:
Ayaz Hafiz said:
(to be fair, type inference and checking for arbitrary union types, including tag unions in the style of flow typing, can be made very fast, ie linear or near-linear with certain techniques like binary decision trees)
Richard Feldman said:
yes this is more what I was thinking about, like error combinations. I got to thinking about unions from TS when I had a look at the open type unions that felt a bit weird to me. But perhaps that's just something to get used to.
I like Zig, they only have type e1 | e2 for error sets. Which lets you do the thing from the first message without the complexities of the rest.
That's probably a very different environment than roc though...
~~One downside of the current approach is as follows.
Take the function below with the inferred type:~~
foo: a -> { arg : a, list : List * }
foo = \arg ->
list = []
{arg, list}
~~ the type makes sense, arg can only be a.
but if we change the definition to: ~~
foo: List [SomeJunk]a -> { arg : List [SomeJunk]a, list : List [SomeJunk]a }
foo = \arg ->
list = List.concat arg [SomeJunk]
{arg, list}
the returntype for the list (rightfully so) includes SomeJunk but due to List.concat expecting equal types, arg is expected to include SomeJunk as well. Effectively poisoning the type of arg in the return. I think it's unintuitive that a function call has an effect on the type of it's arguments. Also note that SomeJunk is propagated to the parameter type as well, which I find not so delightful overall.
On a second thought, this can be avoided by wrapping arg as to only update the result of the type. Which is similar to Haskell.
Last updated: Jun 16 2026 at 16:19 UTC