Skip to content

Experiment: ? re-raises errors (open-row widening at the desugar), replacing hosted try-question widening - #10366

Draft
jaredramirez wants to merge 1 commit into
mainfrom
try-question-reraise
Draft

Experiment: ? re-raises errors (open-row widening at the desugar), replacing hosted try-question widening#10366
jaredramirez wants to merge 1 commit into
mainfrom
try-question-reraise

Conversation

@jaredramirez

@jaredramirez jaredramirez commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Experiment: ? re-raises errors (open-row widening at the desugar), replacing hosted try-question widening

Draft / experiment — this changes language semantics (flips issue #9798 from rejected-by-design to accepted) and exists to evaluate the "re-raising" alternative to polarity discussed in handling err tag accumulation.

What it does

Beginners keep hitting this: a callee's closed error row doesn't compose with the enclosing function's wider error row through ?:

f : I64 -> Try(I64, [Negative])

g : I64 -> Try(I64, [Negative, TooBig])
g = |x| {
    y = f(x)?   # previously: type error — [Negative] (closed) vs [Negative, TooBig]
    ...
}

This PR makes ? re-raise the error instead of passing it through: the desugared Err branch wraps the outgoing payload in a new CIR node, e_reraise_err — conceptually the identity map_err, destructuring each tag and re-constructing it at a fresh open row.

  • Canonicalize: addTryReturnErr wraps the payload in e_reraise_err. Covers suffix ? and the lhs ? handler binop (any ? condition shape, including x? on a stored binding — the node lives in the desugar, not on calls).
  • Check: the node's type is a fresh tag union with the operand's tags and a fresh flexible extension when the operand's row resolved closed; otherwise (flex, rigid-open, non-tag-union payload, nominal, poisoned) it shares the operand's var — a plain pass-through, i.e. exactly today's behavior. No solver mutation: ordinary unification against freshly constructed content. Widening is shallow (payloads, including recursive rows, keep their types).
  • Monotype lowering: lowerReraiseErr — the identity when source/target monotypes are equal, otherwise a generated match that re-tags each tag at the target row's layout (rows can differ in representation, so this is a real coercion, done exactly once at the ? site).
  • design.md: the rule is declared first as "Try Question Error Re-raise", replacing "Hosted Try Question Widening". It is not a solver-mutating rewrite, so it moved out of that section and the Rewrite Inventory shrank.

It subsumes the hosted special case

The compiler already carried exactly this mechanism, gated to hosted functions: the hosted_try_question_widening solver redirect plus generated hosted adapters in monotype lowering (a hosted callee's ABI row is fixed, so the widening had to happen at the use site). The re-raise node does the same thing for every callee, so all of that is deleted:

  • widenTryConditionForExpectedReturn, tryConditionIsDirectHostedCall, and the whole inclusion-probe cluster in Check.zig (9 functions)
  • RedirectRule.hosted_try_question_widening
  • hostedUseNeedsTryAdapter, hostedTryAdapterBody, tryReturnInjectionExpr, errorRowInjectionExpr, and friends in monotype lowering (7 functions), replaced by an invariant that hosted specialization requests always arrive at the declared ABI type

Net diff: −118 lines including the new tests. Hosted ? flows through the same path as everything else (test/fx-open/issue_9963_hosted_try_question_mark.roc still passes; the hosted callee stays specialized at its declared row, which is what the adapter existed to guarantee).

Pros

  • Solves the reported problem. Every beginner report of "tags not accumulating" was error chaining via my_thing()? — that now Just Works, for closed→wider-closed and closed→open rows alike.
  • Annotations keep meaning what they say. No position-dependent reinterpretation of [Negative] in error messages, editor hover, or docs.
  • Local, not type-system-wide. A desugar node + one typing rule + one lowering rule. No variance machinery, no per-parameter variance for nominal types, no contravariance-flips-under-arrows subtleties, no interaction with exhaustiveness of matches on closed unions.
  • Removes a solver-mutating rewrite instead of adding one. The dangerousSetVarRedirect inventory gets smaller; the checker no longer touches the callee's instantiated type at ? sites, so callees always specialize at their declared types.
  • Two-way door. Re-raise only accepts strictly more programs at ? sites. Polarity can still be added later if the non-? cases prove painful; the reverse migration (removing polarity) would break code.
  • Precedent. Rust solved the same problem at the ? operator (via From-conversion), not in the type system's variance rules — and that placement also leaves room to grow ? into genuine error conversions later.

Cons / gaps vs polarity

  • Only helps where a ? appears. Polarity widens output-position rows everywhere; re-raise doesn't cover:

    • Tail-position pass-through: g = |x| if x > 100 Err(TooBig) else f(x) is still a type error (idiomatic fix: Ok(f(x)?), one character of ceremony).
    • Collecting closed rows into one structure: [f(1), h(2)] with two different closed error rows still needs open annotations on the producers.

    Arguably some of this is a feature — a closed annotation is an exhaustiveness promise, and polarity weakens it globally to fix what is overwhelmingly a ?-shaped complaint — but it is the real trade.

  • Check-time resolution. The widening applies when the operand's row is resolved closed at the point the node is checked. A row that is still flexible there shares the var (today's behavior). Defs are checked in order, so in practice annotated/inferred callees are resolved by then, but this is order-sensitive in a way polarity is not. (Declared in the design.md rule; could be moved to a deferred constraint like literal defaulting if it ever bites.)

  • A runtime coercion exists. When the rows differ, the re-raise compiles to a re-tag match on the error path. It's the identity when rows are equal, and error paths are cold, but polarity-with-specialization would instead compile the callee at the wider row (paying compile-time/code-size there instead).

Testing

  • src/check/test/type_checking_integration.zig: issue ? rejects closed error union when returning into open error union #9798 flipped to accepted; accepted pins for closed→wider-closed (the Negative/TooBig example above), ? on a stored binding, a generic callee with a ground closed row, and the lhs ? handler binop widening the handler's closed row; rejected pins for a tag the return row cannot absorb and for shallow widening (a closed payload row does not widen).
  • src/eval/test/eval_issue_tests.zig: three runtime cases — [Negative][Negative, TooBig] (different representations, exercises the real re-tag on all executors), a generic callee specialized at its declared row with the re-tag at the use site, and payload-carrying re-raise into an open row.
  • test/fx-open/: hosted accepted side (issue_9963_hosted_try_question_mark.roc) re-run via run-test-cli on interpreter + dev backends, green; hosted rejected side (hosted_try_question_not_included.roc) still a type error. Fixture comments updated to cite the new rule.
  • Snapshots: 8 files, all pure canonicalization-tree churn (the new node + shifted var indices); no TYPES or PROBLEMS section changed.
  • zig build run-test-zig (full unit suite) and zig build run-test-eval (multi-backend, exercises the re-tag on the interpreter and compiled backends) green locally, except three failures that also fail on plain main on this machine (json decoder allocation-count, fx boxed-erased host boundary x2 — unrelated to ?; CI should arbitrate).
  • The new CIR node changes both serialized layouts, so Constants.CACHE_VERSION and the checked-artifact serialized_layout_version are bumped with fresh golden hashes.

🤖 Generated with Claude Code

https://claude.ai/code/session_019T2TVkkH3TScCj9tV1GB2m

@jaredramirez jaredramirez self-assigned this Jul 25, 2026
… try-question widening

Desugar the Err branch of ? to re-tag the error payload through a new CIR
node, e_reraise_err (conceptually the identity map_err). Checking gives the
node a fresh open copy of the operand's closed tag-union row, so a callee's
closed error row composes with the enclosing return row (flips issue #9798
to accepted, per the rewritten design.md rule 'Try Question Error Re-raise').
Monotype lowering compiles it to a re-tag match (identity when rows agree).

The node subsumes the hosted-try-question-widening special case: the solver
redirect (RedirectRule.hosted_try_question_widening), its gating probes, and
the generated hosted adapters in monotype lowering are all deleted; hosted
callees now always specialize at their declared ABI row, enforced by an
invariant. Net -118 lines.

Bumps the module-cache and checked-artifact serialization versions (new CIR
node changes both layouts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T2TVkkH3TScCj9tV1GB2m
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant