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?
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.
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:
but I agree that there may not be a real use case for a function accepting open tag unions
or at least, I don't know of any!
maybe one will come up eventually
but without open unions, chaining effects that can fail in different ways would be pretty unpleasant :sweat_smile:
@Richard Feldman can you share an example of how an effect chain would use open unions?
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.
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.
https://youtu.be/6qzWm_eoUXM?t=2370
Screen-Shot-2022-02-24-at-5.49.37-PM.png
so open unions mean that File.read
, File.write
, and Http.get
can have different error types, and yet this still works
the type of task
there is something like Task {} [ HttpErr Http.Err, FileReadErr File.ReadErr, FileWriteErr File.WriteErr ]*
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 ]*
if those are all closed unions instead, this is a type mismatch
you just can't chain these together anymore
alternatively, you could make them all have one gigantic error type called like SomethingWentWrong
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)
so like imagine in a language with exceptions, if you just had one Exception
type and that was it
this is the original use case for having open unions in the language incidentally
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.
this is the type of await
:
await : Task a err, (a -> Task b err) -> Task b err
it actually doesn't know about tag unions at all!
Does that example have any function with that benefits from an open tag union input?
the example in the screenshot?
Yeah
Ah, nevermind, I see now that you weren't proposing this as an example of an OTU input.
no it totally is! :smiley:
take a look at the types I wrote for File.read
and such
they all return open unions (instead the error type of the Task
they return
)
Yes, but those are outputs, not inputs, right?
I'm wondering if anyone would notice if Roc started inferring function input tag unions as closed instead of open.
oh gotcha
And in writing that I now realize why
so they're inputs to await
, but not to File.read
but I see what you mean
like a function that only accepts an open union
yeah I don't know of a use case for such a function
Yeah
as opposed to await
, which accepts any type
But I realize now it's a flexibility thing, right?
yeah so this is an example of the value of open unions in the language
like if we only have closed unions, then chaining tasks together would be a lot less pleasant!
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
Yeah it's to avoid a lot of manual annotating
Honestly, I think that might be the easiest way to teach it
Show a realish example with closed tag union values, inputs, outputs, etc
Then show how it takes less code to do it with open unions
Then show how it takes way less code to do it with inferred open unions
That's the real value to developers
Saving keystrokes
eh I don't like pitching things that way
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
Lol, fair enough
the reason I didn't want to use the await
example in the tutorial is that it requires a big detour
but one potential approach is to teach an example platform, and then teach await
without talking about types
and then later go back and explain how the types work out there
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
: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
»
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
That realization is what prompted me to try the REPL - "wait, what would happen if..."
@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
»
where f
's type is inferred as f : [ A ]* -> Str
regardless of the existence & usage of x
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.
Are these bugs more than the ones you sent to me? If so can you please also file issues for them :)
No, just those :smile:
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.
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.
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.
I agree with everything you just said except for the hiding :D what a wild ride of learning...
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!"
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?
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 ]
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
...
...I'll get a type mismatch
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)
and it's not just the annotation either
if I then did this:
when answer is
Foo _ -> "foo"
Bar -> "bar"
Baz -> "baz"
I'd get an exhaustiveness check failure
because I didn't account for Something
so if we just hid the type variable from the user, I think we could end up with some very confusing situations
where it appeared the compiler was incorrectly inferring types
but the actual problem is that it was correctly inferring them and then hiding important information about them :sweat_smile:
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
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
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
because the compiler would be hiding information that's required to understand why certain things are happening!
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.
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.
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:
Crazy how complicated this is to grok.
Is this the most-polymorphic builtin/fundamental in Roc? I'm new to polymorphism, so that might be the primary source of my confusion.
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.
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.
Is that true if it was a *
instead of an e
?
Yeah, maybe record polymorphism is more familiar to me from JSON/POJO experience
Brendan Hansknecht said:
Is that true if it was a
*
instead of ane
?
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
.
Warning: Don't test this stuff in the REPL right now :yum:
@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