Stream: beginners

Topic: Flow based union narrowing


view this post on Zulip Jonathan (Oct 01 2026 at 14:01):

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

view this post on Zulip Richard Feldman (Oct 01 2026 at 16:28):

we talked about it years ago

view this post on Zulip Richard Feldman (Oct 01 2026 at 16:30):

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

view this post on Zulip Richard Feldman (Oct 01 2026 at 16:30):

the main challenge here is runtime representation

view this post on Zulip Richard Feldman (Oct 01 2026 at 16:31):

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

view this post on Zulip Richard Feldman (Oct 01 2026 at 16:32):

a tag union of [A, C] will have A being 0 and C being 1 at runtime

view this post on Zulip Richard Feldman (Oct 01 2026 at 16:33):

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

view this post on Zulip Richard Feldman (Oct 01 2026 at 16:34):

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

view this post on Zulip Richard Feldman (Oct 01 2026 at 16:34):

a fix could be to automatically do a second match behind the scenes to translate 2 to 1

view this post on Zulip Richard Feldman (Oct 01 2026 at 16:35):

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

view this post on Zulip Richard Feldman (Oct 01 2026 at 16:39):

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.

view this post on Zulip Jonathan (Oct 01 2026 at 16:40):

I see :thinking: Thank you for going through it, that is deceptively tricky.

view this post on Zulip Jonathan (Oct 01 2026 at 16:41):

I just found a few prior threads and an rfc here
#beginners > ✔ Is this supposed to work? (tags and branches) @ 💬
https://github.com/roc-lang/rfcs/blob/main/0011-union-refinement.md

view this post on Zulip Jonathan (Oct 01 2026 at 16:44):

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

view this post on Zulip Richard Feldman (Oct 01 2026 at 17:42):

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:

view this post on Zulip David Mell (Oct 01 2026 at 17:43):

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)

view this post on Zulip Jonathan (Oct 01 2026 at 17:50):

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?

view this post on Zulip Richard Feldman (Oct 01 2026 at 18:27):

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:

view this post on Zulip Richard Feldman (Oct 01 2026 at 18:28):

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)

view this post on Zulip Richard Feldman (Oct 01 2026 at 18:29):

optional fields are basically represented as a Try in memory, so they don't have that problem

view this post on Zulip Richard Feldman (Oct 01 2026 at 18:31):

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

view this post on Zulip Jonathan (Oct 01 2026 at 22:05):

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.

view this post on Zulip Jonathan (Oct 01 2026 at 22:07):

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.

view this post on Zulip Jonathan (Oct 01 2026 at 22:10):

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