Skip to content

Add try..finally - #8931

Open
cknitt wants to merge 1 commit into
masterfrom
try-finally
Open

cknitt wants to merge 1 commit into
masterfrom
try-finally

Conversation

@cknitt

@cknitt cknitt commented Oct 20, 2026

Copy link
Copy Markdown
Member

Adds finally blocks to try, with or without a catch. Closes #6974; supersedes #7903, reimplemented on current master.

let load = async () =>
  try {
    await fetchData()
  } catch {
  | JsExn(e) => handleError(e)
  } finally {
    setLoading(false)
  }

The output is a native JavaScript try/catch/finally block:

async function load() {
  try {
    return await fetchData();
  } catch (raw_e) {
    ...
  } finally {
    setLoading(false);
  }
}

Why

finally is how JavaScript code cleans up after await, around break/continue, and in generators. There, cancellation (gen.return(), saga cancellation, a for..of that breaks early) reaches the generator only through finally, never through catch. The workaround of catching, cleaning up and rethrowing stopped being correct once break/continue landed (#8348), because they skip the copied cleanup. This PR is groundwork for generators.

Language

  • try e finally { … } and try e catch { … } finally { … }. The finally block needs braces, as in JavaScript.
  • finally is not a keyword. After a catch, it only starts a finally block when a { follows. Existing code that uses a finally identifier after a try keeps its meaning.
  • The finally block is a statement: its value is dropped, and a non-unit value gets the usual "statements must be unit" error. The try keeps the value of its body or catch.
  • break and continue cannot leave a finally block. JavaScript allows it, but it silently drops a pending exception or return value (ESLint's no-unsafe-finally). Loops inside the block can still use them. If early return (Add early return with provisional extension syntax #8656) lands, return should be rejected the same way.

Implementation

  • Parsetree / typed tree: Pexp_try and Texp_try gain an expression option. The case list is empty when there is no catch.
  • Lambda: a new node Ltryfinally (body, finally). Try/catch/finally is an Ltryfinally around an Ltrywith, which the backend prints as a single JS statement. This leaves Ltrywith unchanged in every pass, and a try without catch needs no dummy exception ident. Optimizer passes treat it like try: never inlined, purity requires both parts to be pure, arity comes from the body.
  • Tail calls: with a finally, the catch handler is no longer in tail position. Otherwise a self tail call in the handler becomes a loop continue that runs the finally before the recursive call instead of after it. Add try..finally syntax #7903 had this bug.
  • PPX bridge: the finally travels on the frozen v0 AST as an internal _res.finally attribute, using the same encoding as for..of.
  • Formatter: comments between the catch cases and the finally's first expression stay with the finally block.
  • Reanalyze: a try without catch no longer counts as catching everything (an empty catch set means catch-all there), so exceptions thrown through or inside a finally are reported.
  • Magic numbers: AST, .cmi and .cmt are bumped.

Answers to the review questions on #7903

  • Is finally a keyword? No, see above.
  • The comments-table change: match and try now share one helper for the scrutinee or body; only the finally handling is new.
  • The extra unify in typecore: gone; the finally is typed with type_statement, like loop bodies.
  • Formatter output: it matches common JS/TS style: } catch { and } finally { on the closing brace's line.

Tests

  • tests/tests/src/try_finally_test.res, runtime:
    • execution order with and without a catch
    • exceptions thrown from the body and from the catch, and unmatched exceptions
    • break/continue, including from a switch, and loops inside the finally
    • nested finally blocks
    • recursion under a finally keeps call order
    • async success and rejection
  • Syntax: parser, printer, comments, the v0 round-trip (ast-mapping/TryExpressions.res), and parse errors (tryFinally.res).
  • ounit: the v0 round-trip keeps attributes and the finally's location.
  • super_errors: break_out_of_finally, continue_out_of_finally, finally_non_unit.
  • Reanalyze: ExnFinally.res, covering exceptions thrown through a finally and inside one, and exceptions caught before it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Fc7LEuyEfynjaKCVEBPmTF

Why:
    JavaScript code cleans up with finally blocks: after an await, around
    break and continue, and in generators, where cancellation is only seen
    by finally. ReScript had no way to express them.

`try` takes `finally { ... }` after its body or after its catch. `finally`
stays an identifier: after a catch it only starts a finally block when a
brace follows. The block is a statement and must not break or continue out
of it. Supersedes #7903.

AST modified (Pexp_try):
Example:
    let f = g =>
      try g() catch {
      | Not_found => 0
      } finally {
        Console.log("done")
      }
Before (diagnostic): `finally` was not valid after a catch.
After (parse):
    expression
      Pexp_try
      expression
        Pexp_apply
        expression
          Pexp_ident "g"
        …
      [
        <case>
          pattern
            Ppat_construct "Not_found"
          expression
            Pexp_constant PConst_int (0,None)
      ]
      Some
        expression
          Pexp_apply
          expression
            Pexp_ident "Console.log"
          …

Lambda added (Ltryfinally):
After (lambda):
    (try
      (try (apply g/1002 unit) with raw_exn/1062
        …)
     finally (seq (js_call[console.log] "done") unit))

JS added (try..catch..finally):
After (js):
    function f(g) {
      try {
        return g();
      } catch (raw_exn) {
        let exn = Primitive_exceptions.internalToException(raw_exn);
        if (exn.RE_EXN_ID === "Not_found") {
          return 0;
        }
        throw exn;
      } finally {
        console.log("done");
      }
    }

Diagnostic added (break out of finally):
Example:
    while true {
      try {
        ()
      } finally {
        break
      }
    }
After (diagnostic):
    `break` cannot leave a `finally` block. Only a loop inside the block can be broken out of.

Review: Why a separate Ltryfinally instead of an optional finally on
Ltrywith? Ltrywith keeps its meaning in every pass, a try without catch
needs no exception ident, and try..catch..finally is an Ltryfinally around
an Ltrywith that the JS backend prints as one statement.
Review: Why is the catch handler no longer in tail position? With a
finally, a self tail call in the handler would become a loop `continue`
that runs the finally before the recursive call instead of after it.
Review: Why reject break and continue in finally? JavaScript allows them,
but they silently drop a pending exception or return value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fc7LEuyEfynjaKCVEBPmTF
Signed-off-by: Christoph Knittel <ck@cca.io>
@github-actions

Copy link
Copy Markdown

@cknitt

cknitt commented Oct 20, 2026

Copy link
Copy Markdown
Member Author

@codex review

@codecov

codecov Bot commented Oct 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.16592% with 42 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.16%. Comparing base (8d1ab9f) to head (636637b).
⚠️ Report is 3 commits behind head on master.

Files with missing lines Patch % Lines
analysis/reanalyze/src/side_effects.ml 0.00% 4 Missing ⚠️
compiler/ml/rec_check.ml 0.00% 4 Missing ⚠️
compiler/ml/typecore.ml 83.33% 4 Missing ⚠️
compiler/core/lam_bounded_vars.ml 0.00% 3 Missing ⚠️
compiler/ml/printast.ml 0.00% 3 Missing ⚠️
compiler/ml/printtyped.ml 0.00% 3 Missing ⚠️
compiler/ml/tast_iterator.ml 0.00% 3 Missing ⚠️
compiler/ml/typedtree_iter.ml 0.00% 3 Missing ⚠️
tests/ounit_tests/ounit_ast_mapper0_tests.ml 90.00% 2 Missing ⚠️
analysis/reanalyze/src/arnold.ml 66.66% 1 Missing ⚠️
... and 12 more
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #8931      +/-   ##
==========================================
+ Coverage   81.13%   81.16%   +0.02%     
==========================================
  Files         456      456              
  Lines       63750    63897     +147     
==========================================
+ Hits        51724    51859     +135     
- Misses      12026    12038      +12     
Files with missing lines Coverage Δ
analysis/reanalyze/src/exception.ml 80.20% <100.00%> (+0.13%) ⬆️
compiler/core/js_dump.ml 93.24% <100.00%> (+0.57%) ⬆️
compiler/core/lam_arity_analysis.ml 86.20% <100.00%> (+0.24%) ⬆️
compiler/core/lam_check.ml 93.26% <100.00%> (+0.13%) ⬆️
compiler/core/lam_exit_count.ml 100.00% <100.00%> (ø)
compiler/core/lam_pass_collect.ml 98.50% <100.00%> (ø)
compiler/core/lam_pass_count.ml 93.97% <100.00%> (ø)
compiler/ext/config.ml 100.00% <ø> (ø)
compiler/ml/ast_helper.ml 88.21% <100.00%> (ø)
compiler/ml/ast_iterator.ml 93.63% <100.00%> (+0.01%) ⬆️
... and 31 more

... and 3 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Swish!

Reviewed commit: 636637beaf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-20T17:52:36.116777Z 636637b Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@cknitt
cknitt marked this pull request as ready for review October 20, 2026 17:54
@pkg-pr-new

pkg-pr-new Bot commented Oct 20, 2026

Copy link
Copy Markdown

Open in StackBlitz

rescript

npm i https://pkg.pr.new/rescript@8931

@rescript/belt

npm i https://pkg.pr.new/@rescript/belt@8931

@rescript/darwin-arm64

npm i https://pkg.pr.new/@rescript/darwin-arm64@8931

@rescript/linux-arm64

npm i https://pkg.pr.new/@rescript/linux-arm64@8931

@rescript/linux-x64

npm i https://pkg.pr.new/@rescript/linux-x64@8931

@rescript/runtime

npm i https://pkg.pr.new/@rescript/runtime@8931

@rescript/win32-x64

npm i https://pkg.pr.new/@rescript/win32-x64@8931

commit: 636637b

@github-actions

Copy link
Copy Markdown

This branch has not been deployed

No deployments
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.

Support try...finally

1 participant