Stream: beginners

Topic: example use case for open tag union


view this post on Zulip Johannes Maas (Feb 24 2022 at 17:19):

I was following the discussion about open vs closed tag unions and started wondering what the use cases for open tag unions are.

To clarify, I understand how they are important for outputs like flexible error types where it's easy to return additional error variants.

I'm wondering about inputs. What's an example of a function that requires an open tag union as its input?

view this post on Zulip Johannes Maas (Feb 24 2022 at 22:29):

I just had the idea to reread the tutorial and Roc for Elm document to see if the example would help me. Unfortunately I couls only find artificial examples like

example : [ Foo Str, Bar Bool ]* -> Bool
example =
  \tag ->
    when tag is
      Foo str -> Str.isEmpty str
      Bar bool -> bool
      _ -> False

So I'm still not sure what a real use case for a function accepting open tag unions might be. I'm starting to get the feeling that maybe we don't need explicit open and closed tags. But I haven't fully grasped it yet.

view this post on Zulip Richard Feldman (Feb 24 2022 at 22:31):

I'm starting to get the feeling that maybe we don't need explicit open and closed tags.

this is a common rite of passage along the way to discovering that we do need both :big_smile:

view this post on Zulip Richard Feldman (Feb 24 2022 at 22:32):

but I agree that there may not be a real use case for a function accepting open tag unions

view this post on Zulip Richard Feldman (Feb 24 2022 at 22:32):

or at least, I don't know of any!

view this post on Zulip Richard Feldman (Feb 24 2022 at 22:32):

maybe one will come up eventually

view this post on Zulip Richard Feldman (Feb 24 2022 at 22:33):

but without open unions, chaining effects that can fail in different ways would be pretty unpleasant :sweat_smile:

view this post on Zulip jan kili (Feb 24 2022 at 22:43):

@Richard Feldman can you share an example of how an effect chain would use open unions?

view this post on Zulip Brendan Hansknecht (Feb 24 2022 at 22:47):

I have one example of wanting an open tag union, but as a result type. Think of a function that takes a lambda as an arg. The lambda would do something and then maybe return an error. The error type of the lambda would be an open tag union. The function as whole would return either one of it's own errors, or one of the errors that the lambda could return.

view this post on Zulip Brendan Hansknecht (Feb 24 2022 at 22:48):

So the lambda returns an open union that is merged with the overall functions error union. The function could not know this union ahead of time due to accepting any lambda.

view this post on Zulip Richard Feldman (Feb 24 2022 at 22:49):

https://youtu.be/6qzWm_eoUXM?t=2370

view this post on Zulip Richard Feldman (Feb 24 2022 at 22:49):

Screen-Shot-2022-02-24-at-5.49.37-PM.png

view this post on Zulip Richard Feldman (Feb 24 2022 at 22:50):

so open unions mean that File.read, File.write, and Http.get can have different error types, and yet this still works

view this post on Zulip Richard Feldman (Feb 24 2022 at 22:51):

the type of task there is something like Task {} [ HttpErr Http.Err, FileReadErr File.ReadErr, FileWriteErr File.WriteErr ]*

view this post on Zulip Richard Feldman (Feb 24 2022 at 22:52):

assuming we have

File.read : Str -> Task Str [ FileReadErr File.ReadErr ]*
File.write : Str, Str -> Task Str [ FileWriteErr File.WriteErr ]*
Http.get : Str -> Task Str [ HttpErr Http.Err ]*

view this post on Zulip Richard Feldman (Feb 24 2022 at 22:52):

if those are all closed unions instead, this is a type mismatch

view this post on Zulip Richard Feldman (Feb 24 2022 at 22:52):

you just can't chain these together anymore

view this post on Zulip Richard Feldman (Feb 24 2022 at 22:52):

alternatively, you could make them all have one gigantic error type called like SomethingWentWrong

view this post on Zulip Richard Feldman (Feb 24 2022 at 22:55):

then they'd be chainable, but when trying to recover from the error, you'd basically be stuck doing a when that either handles any possible thing that could conceivably ever go wrong (including errors from types of I/O operations that aren't even happening here, because they'd all need to be included in that error union)

view this post on Zulip Richard Feldman (Feb 24 2022 at 22:56):

so like imagine in a language with exceptions, if you just had one Exception type and that was it

view this post on Zulip Richard Feldman (Feb 24 2022 at 22:57):

this is the original use case for having open unions in the language incidentally

view this post on Zulip jan kili (Feb 24 2022 at 23:06):

So in this example, is await the function that takes an open tag union as an input? I don't see why it can't take a closed tag union input.

view this post on Zulip Richard Feldman (Feb 24 2022 at 23:06):

this is the type of await:

await : Task a err, (a -> Task b err) -> Task b err

view this post on Zulip Richard Feldman (Feb 24 2022 at 23:06):

it actually doesn't know about tag unions at all!

view this post on Zulip jan kili (Feb 24 2022 at 23:07):

Does that example have any function with that benefits from an open tag union input?

view this post on Zulip Richard Feldman (Feb 24 2022 at 23:07):

the example in the screenshot?

view this post on Zulip jan kili (Feb 24 2022 at 23:08):

Yeah

view this post on Zulip jan kili (Feb 24 2022 at 23:09):

Ah, nevermind, I see now that you weren't proposing this as an example of an OTU input.

view this post on Zulip Richard Feldman (Feb 24 2022 at 23:10):

no it totally is! :smiley:

view this post on Zulip Richard Feldman (Feb 24 2022 at 23:10):

take a look at the types I wrote for File.read and such

view this post on Zulip Richard Feldman (Feb 24 2022 at 23:10):

they all return open unions (instead the error type of the Task they return

view this post on Zulip Richard Feldman (Feb 24 2022 at 23:10):

)

view this post on Zulip jan kili (Feb 24 2022 at 23:11):

Yes, but those are outputs, not inputs, right?

view this post on Zulip jan kili (Feb 24 2022 at 23:11):

I'm wondering if anyone would notice if Roc started inferring function input tag unions as closed instead of open.

view this post on Zulip Richard Feldman (Feb 24 2022 at 23:13):

oh gotcha

view this post on Zulip jan kili (Feb 24 2022 at 23:13):

And in writing that I now realize why

view this post on Zulip Richard Feldman (Feb 24 2022 at 23:13):

so they're inputs to await, but not to File.read

view this post on Zulip Richard Feldman (Feb 24 2022 at 23:13):

but I see what you mean

view this post on Zulip Richard Feldman (Feb 24 2022 at 23:14):

like a function that only accepts an open union

view this post on Zulip Richard Feldman (Feb 24 2022 at 23:14):

yeah I don't know of a use case for such a function

view this post on Zulip jan kili (Feb 24 2022 at 23:14):

Yeah

view this post on Zulip Richard Feldman (Feb 24 2022 at 23:14):

as opposed to await, which accepts any type

view this post on Zulip jan kili (Feb 24 2022 at 23:14):

But I realize now it's a flexibility thing, right?

view this post on Zulip Richard Feldman (Feb 24 2022 at 23:14):

yeah so this is an example of the value of open unions in the language

view this post on Zulip Richard Feldman (Feb 24 2022 at 23:15):

like if we only have closed unions, then chaining tasks together would be a lot less pleasant!

view this post on Zulip Richard Feldman (Feb 24 2022 at 23:15):

also, if we only had closed unions, then other things would need to change - e.g. you'd have to declare the type up front before using one

view this post on Zulip jan kili (Feb 24 2022 at 23:15):

Yeah it's to avoid a lot of manual annotating

view this post on Zulip jan kili (Feb 24 2022 at 23:16):

Honestly, I think that might be the easiest way to teach it

view this post on Zulip jan kili (Feb 24 2022 at 23:16):

Show a realish example with closed tag union values, inputs, outputs, etc

view this post on Zulip jan kili (Feb 24 2022 at 23:16):

Then show how it takes less code to do it with open unions

view this post on Zulip jan kili (Feb 24 2022 at 23:17):

Then show how it takes way less code to do it with inferred open unions

view this post on Zulip jan kili (Feb 24 2022 at 23:17):

That's the real value to developers

view this post on Zulip jan kili (Feb 24 2022 at 23:17):

Saving keystrokes

view this post on Zulip Richard Feldman (Feb 24 2022 at 23:18):

eh I don't like pitching things that way

view this post on Zulip Richard Feldman (Feb 24 2022 at 23:18):

I've gotten burned by so many things where the pitch was "saves you keystrokes" and the fine print turned out to be "and will make you want to tear your hair out in a few months!" that I'm now deeply skeptical of anything where that's the pitch

view this post on Zulip jan kili (Feb 24 2022 at 23:18):

Lol, fair enough

view this post on Zulip Richard Feldman (Feb 24 2022 at 23:19):

the reason I didn't want to use the await example in the tutorial is that it requires a big detour

view this post on Zulip Richard Feldman (Feb 24 2022 at 23:20):

but one potential approach is to teach an example platform, and then teach await without talking about types

view this post on Zulip Richard Feldman (Feb 24 2022 at 23:20):

and then later go back and explain how the types work out there

view this post on Zulip Richard Feldman (Feb 24 2022 at 23:20):

so then by the time you get to open unions, I can just say "ok now let's look at await" which you're already familiar with from the earlier parts of the tutorial

view this post on Zulip jan kili (Feb 24 2022 at 23:30):

:face_palm: I just realized that tag union inputs for functions with no _ -> aren't inferred as open... they're inferred as closed. I somehow mis-assumed that, which is why I was asking about "would anyone notice"... whoops

» f = \t ->     when t is         A -> "a"         B -> "b" f

<function> : [ A, B ] -> Str

»

view this post on Zulip Ayaz Hafiz (Feb 24 2022 at 23:33):

They used to be inferred as open, but we have to infer them as closed or otherwise it's a soundness bug - then you could pass in a C, and we would compile the program but crash at runtime

view this post on Zulip jan kili (Feb 24 2022 at 23:34):

That realization is what prompted me to try the REPL - "wait, what would happen if..."

view this post on Zulip jan kili (Feb 24 2022 at 23:41):

@Johannes Maas I still don't know of a real-world example for a function input OTU, but this is a minimal artificial example:

» f = \t ->     when t is         A -> "a"         _ -> "?" x = B f x

"?" : Str

»

view this post on Zulip jan kili (Feb 24 2022 at 23:42):

where f's type is inferred as f : [ A ]* -> Str regardless of the existence & usage of x

view this post on Zulip jan kili (Feb 24 2022 at 23:44):

Warning to anyone else going to the REPL to learn tag unions, I've discovered multiple tag-union-related bugs/gaps in the compiler that hinder experimental learning.

view this post on Zulip Ayaz Hafiz (Feb 24 2022 at 23:45):

Are these bugs more than the ones you sent to me? If so can you please also file issues for them :)

view this post on Zulip jan kili (Feb 24 2022 at 23:48):

No, just those :smile:

view this post on Zulip Johannes Maas (Feb 25 2022 at 07:02):

I think I see now that you need OTUs for proper type inference. Because if you have a when with a catch-all but you don't give type annotation, you can't know from looking at that when what tags are put into it.

And it is difficult to hide that from developers because they need to know whether they can input just the explicit branches or whether there is a catch-all branch.

Which means that they need to concern themselves with input OTUs, even if in practice they wouldn't really want to ask for input OTUs.

So in a way, you wouldn't really need it in explicit type annotations, but it is necessary for type inference and thus for showing the type of something.

view this post on Zulip Johannes Maas (Feb 25 2022 at 11:25):

I'm still thinking about this. :big_smile:

I have feeling about it, and I think it boils down to: Do we need closed tag unions for outputs? I think not, because why prevent someone from piecing them together?

If for explicit type annotations we can always use closed inputs and we do not need closed outputs, then the only case where open unions are relevant is when we infer the type of a when with a catch-all. Then we need to say "I know the input can be one of these tags, but you could also give me any other".

In that case maybe we can never show whether a tag union is open or closed (that's quite naturally determined by wether it's an input or an output), except when we need to show the inferred type for a catch-all conditional where we need to admit that it could accept more tags than we can list.

view this post on Zulip Johannes Maas (Feb 25 2022 at 11:26):

Basically: If this holds, then the developer wouldn't need to worry about openness. It would just be something that sometimes shows up in inferred types to mark catch-alls.

view this post on Zulip jan kili (Feb 25 2022 at 12:22):

I agree with everything you just said except for the hiding :D what a wild ride of learning...

view this post on Zulip jan kili (Feb 25 2022 at 12:25):

But also I do see a use case for closed outputs, and it's best exemplified by something Richard mentioned: for something like Bool : [ True, False ] you might want a program to fail if it ever tried to parse a Bool with a function like parse : [ True, False, Maybe, Other ]* because "no, it can only be true or false!"

view this post on Zulip Richard Feldman (Feb 25 2022 at 12:39):

so today, let's say I write this function (not saying this is a good function to write, but it's certainly possible, so the compiler has to do something with it!)

blah : [ Foo Str, Bar ]a -> [ Foo Str, Bar, Baz ]a
blah = \fooOrBar ->
    when fooOrBar is
        Foo "" -> Bar
        Foo _ -> Foo "something"
        Bar -> Baz
        other -> other

let's say in this proposed design I don't give this function a type annotation, but I implement it and put it into the repl. What type should the compiler infer and display for me?

view this post on Zulip Richard Feldman (Feb 25 2022 at 12:41):

one answer could be that the compiler tracks the type variable (this is non-optional btw; even if the type variable is never displayed to the user, it must be there at least behind the scenes in order for the compiler to work properly) and just doesn't render it, so the compiler would say that this is the type:

blah : [ Foo Str, Bar ] -> [ Foo Str, Bar, Baz ]

view this post on Zulip Richard Feldman (Feb 25 2022 at 12:44):

however, if I then put this into the repl:

myFunction : [ Foo Str, Bar, Something ] -> Str
myFunction = \arg ->
    answer : [ Foo Str, Bar, Baz ]
    answer = blah arg

    ...

view this post on Zulip Richard Feldman (Feb 25 2022 at 12:44):

...I'll get a type mismatch

view this post on Zulip Richard Feldman (Feb 25 2022 at 12:45):

because although blah allegedly (according to the type annotation the repl told me the compiler inferred for it) returns [ Foo Str, Bar, Baz ], and although I copy/pasted that exact type as the annotation for answer - which I got by calling blah! - those types don't line up because my annotation is missing the Something type. (Because actually blah returns any extra tags you give it, even though it doesn't say it does)

view this post on Zulip Richard Feldman (Feb 25 2022 at 12:45):

and it's not just the annotation either

view this post on Zulip Richard Feldman (Feb 25 2022 at 12:45):

if I then did this:

view this post on Zulip Richard Feldman (Feb 25 2022 at 12:46):

when answer is
    Foo _ -> "foo"
    Bar -> "bar"
    Baz -> "baz"

view this post on Zulip Richard Feldman (Feb 25 2022 at 12:46):

I'd get an exhaustiveness check failure

view this post on Zulip Richard Feldman (Feb 25 2022 at 12:46):

because I didn't account for Something

view this post on Zulip Richard Feldman (Feb 25 2022 at 12:46):

so if we just hid the type variable from the user, I think we could end up with some very confusing situations

view this post on Zulip Richard Feldman (Feb 25 2022 at 12:46):

where it appeared the compiler was incorrectly inferring types

view this post on Zulip Richard Feldman (Feb 25 2022 at 12:47):

but the actual problem is that it was correctly inferring them and then hiding important information about them :sweat_smile:

view this post on Zulip Richard Feldman (Feb 25 2022 at 13:15):

a related problem with the "hide the type variables" idea is that if I had this code:

foo : [ A, B ]
foo = doStuff "blah"

bar : [ C, D ]
bar = doOtherStuff "blah"

if condition then
    foo
else
    bar

this might or might not type-check depending on whether the doStuff and doOtherStuff functions happen to be doing an exhaustive when on the values they return

view this post on Zulip Richard Feldman (Feb 25 2022 at 13:16):

because the type annotations [ A, B ] and [ C, D ] in this hypothetical design could mean either open or closed tag unions, so they wouldn't change even if the implementation of the function changed in a way that made this code invalid

view this post on Zulip Richard Feldman (Feb 25 2022 at 13:18):

so yeah, when it comes to this:

maybe we can never show whether a tag union is open or closed

it would create situations where no amount of learning can prevent confusion

view this post on Zulip Richard Feldman (Feb 25 2022 at 13:18):

because the compiler would be hiding information that's required to understand why certain things are happening!

view this post on Zulip Brendan Hansknecht (Feb 25 2022 at 15:01):

I have feeling about it, and I think it boils down to: Do we need closed tag unions for outputs? I think not, because why prevent someone from piecing them together?

Sometimes you legitimately have a specific list that you want to return a value from. It would be a bug if someone added a new type to the list. You would still want a close union output. The stoplight can only be red, green, or yellow. It wouldn't make any sense if someone could add to that union. So a closed union output.

view this post on Zulip Brendan Hansknecht (Feb 25 2022 at 15:04):

Also, if a function returns an open union, I believe that everything that depends on it will need a catch all case. That may not be desired.

view this post on Zulip Johannes Maas (Feb 25 2022 at 17:40):

Also, if a function returns an open union, I believe that everything that depends on it will need a catch all case. That may not be desired.

True, I think I still have the wrong mental model. :sweat_smile:

view this post on Zulip Brendan Hansknecht (Feb 25 2022 at 17:42):

Crazy how complicated this is to grok.

view this post on Zulip jan kili (Feb 25 2022 at 18:33):

Is this the most-polymorphic builtin/fundamental in Roc? I'm new to polymorphism, so that might be the primary source of my confusion.

view this post on Zulip Ayaz Hafiz (Feb 25 2022 at 18:44):

Brendan Hansknecht said:

Also, if a function returns an open union, I believe that everything that depends on it will need a catch all case. That may not be desired.

This is not necessarily true. For example:

f : Bool -> [A, B]e
f = \b -> if b then A else B

when f True is
  A -> "is a"
  B -> "is b"

This will compile without needing a catch all case in the when expression because at the call site f True, we will infer that we need [A, B]e to be equal to [A, B]. And e will be specialized to [], so we will generate a specific version of f that has exactly the function signature Bool -> [A, B] for this use case.

view this post on Zulip Ayaz Hafiz (Feb 25 2022 at 18:45):

JanCVanB said:

Is this the most-polymorphic builtin/fundamental in Roc? I'm new to polymorphism, so that might be the primary source of my confusion.

No, records behave in the same way, just "in the opposite direction". For example I can say {a: Str, b: Str}e as a type that can be specialized to be more specific than {a: Str, b: Str}, but must be at least as specific as that.

view this post on Zulip Brendan Hansknecht (Feb 25 2022 at 18:46):

Is that true if it was a * instead of an e?

view this post on Zulip jan kili (Feb 25 2022 at 18:47):

Yeah, maybe record polymorphism is more familiar to me from JSON/POJO experience

view this post on Zulip Ayaz Hafiz (Feb 25 2022 at 19:04):

Brendan Hansknecht said:

Is that true if it was a * instead of an e?

Yes. * is just a special syntax for when there is no other type variable of the same name linked to each other. So {} -> [A]a and {} -> [A]* are the same thing, but [A]c -> [B]c cannot be replaced by [A]* -> [B]*, because the former says "the tags instantiated in c in the input are also shared in the output" while the latter would be equivalent to [A]c -> [B]d.

view this post on Zulip jan kili (Feb 26 2022 at 05:51):

Warning: Don't test this stuff in the REPL right now :yum:

view this post on Zulip jan kili (Feb 26 2022 at 05:52):

@Ayaz Hafiz

Are these bugs more than the ones you sent to me? If so can you please also file issues for them :)

:point_up: There they are


Last updated: Jul 05 2025 at 12:14 UTC