Stream: ideas

Topic: handling err tag accumulation


view this post on Zulip Jared Ramirez (Jul 21 2026 at 19:34):

Building off of #bugs > Broken Exercism exercises:

Currently, when you use ?, we unwrap the Try and if it's an error, return the error. eg:

f : I64 -> Try(I64, [Negative])
f = |x| if x >= 0 Ok(2 * x) else Err(Negative)

g : I64 -> Try(I64, [Negative, TooBig])
g = |x| {
    y = f(x)?
    ...
}

becomes:

g : I64 -> Try(I64, [Negative, TooBig])
g = |x| {
    y = match(f(x)) {
      Try.Ok(a) => return Try.Ok(a)
      Try.Err(e) => return Try.Err(e)
    }
    ...
}

this works fine, but means that f's return err type [Negative] must be open, so it can compose with gs return type.

the way we solved this in the rust compiler was with "polarity". in a nutshell, whenever you used f, the compiler would allow it to be either open or closed, depending on how it was used. this worked!

another way we could solve it is by changing how ? desugars. instead of above, it could desugar to:

g : I64 -> Try(I64, [Negative, TooBig])
g = |x| {
    y = match(f(x)) {
      Try.Ok(a) => Try.Ok(a)
      Try.Err(e) => match(e) {
         Negative => return Negative
       }
    }
    ...
}

here, since we're destructuring & re-creating the err tag, we can re-create it as open, which is compatible with g. we can call this "re-raising the error"

view this post on Zulip Jared Ramirez (Jul 21 2026 at 19:34):

polarity:

re-raising

view this post on Zulip Jared Ramirez (Jul 21 2026 at 19:38):

it looks like we as added something like this, but hen removed it

that was different though in that it allows a closed tag union to unify with an open one, whereas the above proposal is equiv to writing .map_err(|MyErr| MyErr) by hand today.

view this post on Zulip Aurélien Geron (Jul 21 2026 at 19:43):

It looks like .map_err(|e| e) doesn't actually work. For example, in the code above y = f(x).map_err(|e| e)? still causes the same error as y = f(x)?. However, y = f(x).map_err(|_| Negative)? does work.

view this post on Zulip Jared Ramirez (Jul 21 2026 at 19:44):

thanks, yup, i updated!

view this post on Zulip Aurélien Geron (Jul 21 2026 at 19:51):

I like your option 2. When writing function f, since it can only return error Negative, it feels natural for the return type to be Try(I64, [Negative]). Keeping it closed is useful for exhaustive match statements. But having some syntax sugar for .map_err(|Negative| Negative) would be much appreciated.

view this post on Zulip Karl (Jul 21 2026 at 19:53):

f : I64 -> Try(I64, [Negative])
f = |x| if x >= 0 Ok(2 * x) else Err(Negative)

g : I64 -> Try(I64, [TooBig, MappedNegative])
g = |x|{
    y = f(x) ? |_| MappedNegative
    if y > 10 Err(TooBig) else Ok(y)
}

And if you leave out the |_| you'll get MappedNegative(Negative) or you can leave off the type declaration on f and the postfix ? will work

view this post on Zulip Bryce Miller (Jul 21 2026 at 20:19):

For some reason I was thinking that at least at some point in the past it was possible to have tag unions compose together for error handling, but also to allow exhaustive pattern matching. So I’d get a compile error if there was a new error to handle rather than the new error falling into a catchall case. Was this ever the case or did I misunderstand how it worked?

view this post on Zulip Bryce Miller (Jul 21 2026 at 20:22):

Definitely willing to admit that I never understood this properly, as I feel my understanding is shaky now :sweat_smile:

view this post on Zulip Karl (Jul 21 2026 at 20:29):

f = |x| if x >= 0 Ok(2 * x) else Err(Negative)

g = |x|{
    y = f(x) ? |_| MappedNegative
    if y > 10 Err(TooBig) else Ok(y)
}

root = |x| {
    match g(x) {
        Ok(out) => out.to_str(),
        Err(TooBig) => "Too big!",
    }
}

And then roc check:

┌──────────────────────┐
│ NON EXHAUSTIVE MATCH ├─ This match expression doesn't cover all possible cases. ────────────────────────┐
└┬─────────────────────┘                                                                                  │
 │                                                                                                        │
 │  match g(x) {                                                                                          │
 │      Ok(out) => out.to_str(),                                                                          │
 │      Err(TooBig) => "Too big!",                                                                        │
 │  }                                                                                                     │
 │                                                                                                        │
 └──────────────────────────────────────────────────── /Users/grayrest/dev/roc/roc-solid/example.roc:43:5 ┘

    The value being matched on has type:
                        Try(ok, [MappedNegative, TooBig, ..])
  where [
    a.from_quote : Str -> Try(a, [BadQuotedBytes(Str)]),
    b.from_numeral : Numeral -> Try(b, [InvalidNumeral(Str)]),
    b.is_gte : b, b -> Bool,
    ok.from_numeral : Numeral -> Try(ok, [InvalidNumeral(Str)]),
    ok.is_gt : ok, ok -> Bool,
    ok.times : ok, b -> ok,
    ok.to_str : ok -> a,
  ]

    Missing patterns:
            Err MappedNegative

view this post on Zulip Aurélien Geron (Jul 21 2026 at 21:17):

This is pretty cool. But if you rename MappedNegative to simply Negative, then it feels weird having to write y = f(x) ? |_| Negative. And if the error had some payload, you would need to write y = f(x) ? |Negative(x)| Negative(x). I would really prefer simply y = f(x)? in this case. It's really quite common.

view this post on Zulip Luke Boswell (Jul 21 2026 at 22:07):

Bryce Miller said:

For some reason I was thinking that at least at some point in the past it was possible to have tag unions compose together for error handling, but also to allow exhaustive pattern matching. So I’d get a compile error if there was a new error to handle rather than the new error falling into a catchall case. Was this ever the case or did I misunderstand how it worked?

I thought we had this too

view this post on Zulip Jared Ramirez (Jul 21 2026 at 22:32):

^ this existed in rust compiler via polarity! aka option 1 above

view this post on Zulip Jonathan (Jul 21 2026 at 22:57):

Jared Ramirez said:

[...] the type signature can mean open or closed, depending on the how the function is used [...]

I don't know how mistaken I am, but I thought that open unions basically allow

which are kind of different mechanisms/use cases that exploit the same property of an open union. If the function has opted in to returning an open union through an annotation, isn't that kind of different to when the return type has been made open through polarity to ensure it can be merged into another union?

view this post on Zulip Jonathan (Jul 21 2026 at 23:00):

Will option 1 ever lead to cases (I'm guessing it can but I just can't think of one) where the returned type now has to be handled as if it has unknown extra tags that need to be matched against with a catch all?

view this post on Zulip Jonathan (Jul 21 2026 at 23:05):

I think I mean that I don't understand the downside of having silently open tag unions in some cases, unless the result after the merge of [A] and [B] resolves to [A, B, ..], which wouldn't be great.

view this post on Zulip Jared Ramirez (Jul 21 2026 at 23:43):

yes, you’re right! “the call site determines” is exactly the same as an open tag union! in that case, the call site also decides.

the core thing polarity does is: for any tag union in the “output” position (any tag union being returned by a function), automatically make that tag union open regardless of how it’s written in the annotation. then the places that need it to be open can extend it, the places that need it to be closed do not.

sorry that my earlier explanation was not clear, i was too in the weeds and forgot to include this (important) detail!

view this post on Zulip Bryce Miller (Jul 22 2026 at 12:08):

See #bugs > Broken Exercism exercises @ 💬

Basically, I think my confusion was due to not realizing input vs output position was significant


Last updated: Jul 23 2026 at 13:15 UTC