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"
polarity:
re-raising
? suffix syntax (may not be a con in practice)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.
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.
thanks, yup, i updated!
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.
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
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?
Definitely willing to admit that I never understood this properly, as I feel my understanding is shaky now :sweat_smile:
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
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.
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
^ this existed in rust compiler via polarity! aka option 1 above
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
.. admits adding the other new tagswhich 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?
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?
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.
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!
See
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