Say I have a function that matches on a tag union. One tag is reinterpreted but the rest are passed through using a catchall. Should the catchall's union type have the matched tag subtracted?
The case I have right now is an sql query, where NoRowsReturned is reinterpreted a different error (SubsetNotFound) and the rest are wrapped:
match q.query!(query, [{ name: ":name", value: String(name) }], decoder) {
Ok(count) => Ok(count)
Err(NoRowsReturned) => Err(SubsetNotFound)
Err(otherwise) => Err(QueryError(otherwise))
}
The problem is that otherwise still has the tag included in its type ([NoRowsReturned, NoSuchField, ...]) and so the type signature becomes Try(U64, [SubsetNotFound, QueryError([NoRowsReturned, ...])]), even though NoRowsReturned cannot appear in QueryError.
If I manually annotate the function to say that NoRowsReturned cannot happen, it is an error:
This definition can produce the tag NoRowsReturned but the annotated tag union does not list it.
The only solution I have currently is to match on all the other possible error tags to re-return them individually, a bit like we have to do sometimes to re-wrap a Try (unsure if this has changed since polarity). It's not so bad, but I was wondering whether that's likely to stay? (/interested in the reasoning around this kind of feature).
we talked about it years ago
I think someone tried implementing it a year ago or so (I'm not sure what the Zulip thread was though) and there were unexpected problems with the implementation - I think they got stuck and decided to stop, if I remember right
the main challenge here is runtime representation
if I have a tag union of [A, B, C] then at runtime we represent this as an integer where A is 0, B is 1, and C is 2
a tag union of [A, C] will have A being 0 and C being 1 at runtime
so the problem is if you have a match on B which eliminates it, and then another branch that you want to just treat [A, C] as the remaining possibilities, there's more work that has to happen to translate the numbers at runtime
otherwise if the way you reach that branch is because you had C at runtime, that means you had a 2 in memory whereas everything that's working on the [A, C] union only knows how to handle 0 and 1, so you get undefined behavior
a fix could be to automatically do a second match behind the scenes to translate 2 to 1
but it would be nonobvious from the code that a second full conditional is happening, plus I think that was the thing that ran into unexpected implementation problems when someone tried it
so overall my current thinking is that we don't have a good solution here, even though it looks like it should work. Maybe in the future we can find a design that works, but at the moment it's blocked on having an acceptable design.
I see :thinking: Thank you for going through it, that is deceptively tricky.
I just found a few prior threads and an rfc here
https://github.com/roc-lang/rfcs/blob/main/0011-union-refinement.md
What is the difference though between some automatic behind the scenes remapping, and my manual mapping below? Is it just a matter of making the branching explicit?
match r {
Ok(v) => Ok(v)
Err(ErrorA) => Err(AnotherName)
Err(ErrorB) => Err(ErrorB)
...
}
in theory yes, although I remember when someone went to implement the automatic remapping, they ran into something unexpected and got stuck...but I can't find the thread anymore and I don't remember exactly what the problem they ran into was :sweat_smile:
In the next couple of months, I was going to experiment with trying to implement this, if no one else got around to it first.
We'd always want to do the match remapping for tags that ever cross the host boundary. However, to avoid it a lot of the time, we could merge the runtime representations of any tags that interact internally. (Obviously, this will have a tag size penalty in some cases)
Obviously, this will have a tag size penalty in some cases
I'm imagining something like with optional fields in records, where you need to maintain padding for compatibility?
easier said than done haha...now imagine you have other functions that accept [A, C] and they get passed a "normal" C sometimes, and order times the special ones from this branch :sweat_smile:
the answer could be that you get a new specialization of the function because this is a secret form of polymorphism that's not reflected in the type system, but then you get the question of what happens at the host boundary (and maybe also module boundaries)
optional fields are basically represented as a Try in memory, so they don't have that problem
a downside of always automatically doing the match silently is that you might not care - e.g. you're just going to turn around and pass it to a "log this as an error" function - and then you're paying the runtime cost of a match that has no benefit and you can't opt out of it because we do it automatically for you
Hmm. Perhaps it would be something you would opt into by annotating the reduced union? Not a fan of that though because it would be unclear hidden behaviour.
In another thread there was the idea about a .widen() method that could be automatically added to unions to solve the issue going the other way. I hit this today, I don't know if it's a bug, but sometimes it's ok to |e| { return Err(e) } but not use ?. I was thinking that polarity changes would make them behave the same.
Anyhow, that's separate. But it seems like this is a sibling case that could be solved with a .narrow() method. I'm not sure what the semantics would be exactly, but you'd use it like
match r {
Ok(v) => Ok(v)
Err(SomeError) => Ok(other_value)
Err(other_errors) => Err(other_errors.narrow())
}
to opt into the matching, though that would also be quite a special-cased behaviour.
Last updated: Oct 15 2026 at 17:56 UTC