Stream: ideas

Topic: Type union unions and intersection


view this post on Zulip J.Teeuwissen (Sep 10 2022 at 17:25):

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.

view this post on Zulip Richard Feldman (Sep 10 2022 at 19:22):

so union types have an unfortunate property of defeating the performance optimization that makes Roc's type checker fast in practice

view this post on Zulip Richard Feldman (Sep 10 2022 at 19:23):

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"

view this post on Zulip Richard Feldman (Sep 10 2022 at 19:26):

there are some other downsides, for example in error message quality

view this post on Zulip Richard Feldman (Sep 10 2022 at 19:27):

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`

view this post on Zulip Richard Feldman (Sep 10 2022 at 19:27):

I also suspect union types lead to more complicated API designs

view this post on Zulip Richard Feldman (Sep 10 2022 at 19:28):

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

view this post on Zulip Richard Feldman (Sep 10 2022 at 19:29):

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

view this post on Zulip Richard Feldman (Sep 10 2022 at 19:29):

there are similar tradeoffs around optional function arguments or multi-arity functions

view this post on Zulip Richard Feldman (Sep 10 2022 at 19:31):

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

view this post on Zulip Richard Feldman (Sep 10 2022 at 19:31):

so all of those things considered, I don't think it's worth it :big_smile:

view this post on Zulip Richard Feldman (Sep 10 2022 at 19:44):

to be fair though, something I would like is the ability to say something like File.writeUtf8 : Path | Str, Str -> Task …

view this post on Zulip Richard Feldman (Sep 10 2022 at 19:45):

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

view this post on Zulip Richard Feldman (Sep 10 2022 at 19:47):

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

view this post on Zulip J.Teeuwissen (Sep 10 2022 at 19:48):

I hadn't even though about the overload aspect of this really :smile: . And an exponential slowdown by itself sounds like a deal-breaker.

view this post on Zulip Richard Feldman (Sep 10 2022 at 19:50):

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)

view this post on Zulip J.Teeuwissen (Sep 10 2022 at 19:50):

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.

view this post on Zulip Arya Elfren (Sep 10 2022 at 20:31):

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...

view this post on Zulip J.Teeuwissen (Sep 11 2022 at 06:34):

~~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