Skip to content

Pass the origin of a synthesized match as an argument, not an attribute - #9128

Merged
cknitt merged 1 commit into
masterfrom
match-origin-9126
Oct 29, 2026
Merged

cknitt merged 1 commit into
masterfrom
match-origin-9126

Conversation

@cknitt

@cknitt cknitt commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

Fixes #9126.

The type checker rewrites two constructs into a match: a let destructuring a GADT constructor, and an optional parameter's default. It marked these matches with the string attributes #let_gadt_pattern and #optional_arg_default so their diagnostics read as part of a function rather than a switch. Both attributes were created and read only inside typecore.ml; nothing after typing reads them (translcore, printtyped, the mappers, reanalyze, editor analysis).

  • type_expect takes ?match_context, read only by the Pexp_match case. The GADT let rewrite passes `Function.
  • type_let takes ?optional_default, which replaces the attribute scan (is_fake_let) and passes `Function to the default's match.
  • let.unwrap stays an attribute, since it is the parsed form of let? and the formatter prints it back.

What the origin selects

The diagnostics below are unchanged by this PR; they show what each origin is for. Each is compared with the same code written as a switch, which is what the compiler would report without the origin.

GADT let is typed as switch value { | Any(x, f) => f(x) }, so the rest of the block is the case's body:

type rec any = Any('a, 'a => string): any

let get = (value): int => {
  let Any(x, f) = value
  f(x)
}
This has type: string
But it's expected to have type: int

Written as a switch, the same code gets the switch wording; before #9081 the let got it too, although its source has no switch:

This has type: string
But this switch is expected to return: int
All branches in a switch must return the same type.
To fix this, change your branch to return the expected type.

Optional default is typed as let count = switch countOpt { | Some(x) => x | None => "one" }, with the default in the synthesized None case:

let f = (~count: int="one", ()) => count + 1
This has type: string
But it's expected to have type: int

The hand-written let count: int = switch count { | Some(c) => c | None => "one" } gets "But this switch is expected to return: int / All branches in a switch must return…" instead.

optional_default also picks the unused-variable warning. In let f = (~count=1, ()) => "!", count is bound by the synthesized let, but it is a parameter in the source, so it gets warning 27 like any other unused parameter rather than warning 26 for an unused let variable.

No change to the typed tree's shape or to .cmt files. A typed origin field on Texp_match (the issue's first option) would add a field nothing reads; a typed GADT let form (second option) would touch translation, rec_check, the printers and analysis for no new behaviour.

Also fixed along the way:

  • The rewrite required the binding to have no attributes, so a doc comment, @inline or @warning in front of let Any(x, f) = value was rejected with "This pattern introduces an existential type … not allowed here". The binding's attributes now go on the case pattern.
  • @warning on a pattern didn't cover that pattern's unused variables (| @warning("-27") Some(x) => 1 still warned), because type_pattern registers those checks after leaving the pattern's warning scope. It now registers them inside it.

Known limitation: @warning("-8") on a non-exhaustive GADT let still doesn't silence warning 8, since exhaustiveness is checked for the match as a whole (same as for a switch case).

New tests:

  • tests/build_tests/super_errors/fixtures/pattern_warning_scope.res: @warning silences unused variables on a GADT let with a doc comment and on a switch case pattern; an unannotated case still warns.
  • tests/tests/src/gadt_test.res: showOne runs a GADT let with a doc comment and @warning in front.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Kbf3gHnoR8Goys4AMxET4q

Why:
    The type checker rewrites a `let` destructuring a GADT constructor, and
    an optional parameter's default, into a match, and marked that match
    with the string attributes `#let_gadt_pattern` and
    `#optional_arg_default` so that it gets function-style diagnostics
    rather than a switch's (#9126). Both are made and read inside typecore
    and nothing after typing reads them. `type_expect` now takes
    `?match_context` and `type_let` takes `?optional_default`; only
    `let.unwrap`, which comes from the parsed `let?`, stays an attribute.
    The GADT rewrite also required the binding to have no attributes, so a
    doc comment or `@warning` in front of it was an error; its attributes
    now go on the case pattern, and `@warning` on a pattern now covers that
    pattern's unused variables.

Typedtree modified (let destructuring a GADT constructor):
Example:
    type rec any = Any('a, 'a => string): any

    let get = value => {
      let Any(x, f) = value
      f(x)
    }
Before (typed):
    expression
      attribute "#let_gadt_pattern"
        []
      Texp_match
      expression
        Texp_ident "value/1004"
      [
        <case>
          pattern
            Tpat_construct "Any"
          …
After (typed):
    expression
      Texp_match
      expression
        Texp_ident "value/1004"
      [
        <case>
          pattern
            Tpat_construct "Any"
          …

Diagnostic removed (attribute in front of a GADT let):
Example:
    type rec any = Any('a, 'a => string): any

    let get = value => {
      /** Destructures the existential. */
      let Any(x, f) = value
      f(x)
    }
Before (diagnostic):
    This pattern introduces an existential type from a GADT constructor,
    which is not allowed here. Match on it inside a switch instead.
After (diagnostic): compiles

Diagnostic removed (unused variable of a pattern with @warning):
Example:
    let silenced = o =>
      switch o {
      | @warning("-27") Some(x) => 1
      | None => 0
      }
Before (diagnostic):
    Warning number 27
    unused variable x.
After (diagnostic): no warning

Review: Why not a typed origin field on `Texp_match`, as #9126 suggests? Nothing after typing reads the origin; the field would change the typed tree for no consumer.
Review: Does `@warning("-8")` on a GADT let cover a non-exhaustive pattern? No: the exhaustiveness check runs for the whole match, outside the case pattern's warning scope, as for a switch case.

Fixes: #9126

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

Copy link
Copy Markdown

Changelog

🐛 Bug fix

  • Accept an attribute or doc comment in front of a let that destructures a GADT constructor, and let @warning on a pattern cover that pattern's unused variables.

Semantic Diff

1 commit · Full page

Pass the origin of a synthesized match as an argument, not an attribute

3386811 · cknitt + Claude · Oct 29, 2026, 18:08 UTC

Why

The type checker rewrites a let destructuring a GADT constructor, and an optional parameter's default, into a match, and marked that match with the string attributes #let_gadt_pattern and #optional_arg_default so that it gets function-style diagnostics rather than a switch's (#9126). Both are made and read inside typecore and nothing after typing reads them. type_expect now takes ?match_context and type_let takes ?optional_default; only let.unwrap, which comes from the parsed let?, stays an attribute. The GADT rewrite also required the binding to have no attributes, so a doc comment or @warning in front of it was an error; its attributes now go on the case pattern, and @warning on a pattern now covers that pattern's unused variables.

Typedtree modified (let destructuring a GADT constructor)

type rec any = Any('a, 'a => string): any

let get = value => {
  let Any(x, f) = value
  f(x)
}

typed

 expression
-  attribute "#let_gadt_pattern"
-    []
   Texp_match
   expression
     Texp_ident "value/1004"
     <case>
       pattern
         Tpat_construct "Any"
       …

Diagnostic removed (attribute in front of a GADT let)

type rec any = Any('a, 'a => string): any

let get = value => {
  /** Destructures the existential. */
  let Any(x, f) = value
  f(x)
}

diagnostic

-This pattern introduces an existential type from a GADT constructor,
-which is not allowed here. Match on it inside a switch instead.
+compiles

Diagnostic removed (unused variable of a pattern with @warning)

let silenced = o =>
  switch o {
  | @warning("-27") Some(x) => 1
  | None => 0
  }

diagnostic

-Warning number 27
-unused variable x.
+no warning

Fixes: #9126

Review

@codecov

codecov Bot commented Oct 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 81.71%. Comparing base (6ea63e5) to head (3386811).

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #9128      +/-   ##
==========================================
- Coverage   81.71%   81.71%   -0.01%     
==========================================
  Files         460      460              
  Lines       64761    64757       -4     
==========================================
- Hits        52918    52914       -4     
  Misses      11843    11843              
Files with missing lines Coverage Δ
compiler/ml/typecore.ml 89.21% <100.00%> (-0.02%) ⬇️

... and 2 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.

@pkg-pr-new

pkg-pr-new Bot commented Oct 29, 2026

Copy link
Copy Markdown

Open in StackBlitz

rescript

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

@rescript/belt

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

@rescript/darwin-arm64

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

@rescript/linux-arm64

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

@rescript/linux-x64

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

@rescript/runtime

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

@rescript/win32-x64

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

commit: 3386811

@github-actions

Copy link
Copy Markdown

@cknitt

cknitt commented Oct 29, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 29, 2026 •

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-29T19:49:35.228149Z 3386811 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.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. More of your lovely PRs please.

Reviewed commit: 3386811444

ℹ️ 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".

@cknitt
cknitt requested a review from cristianoc October 29, 2026 19:56
@cknitt
cknitt marked this pull request as ready for review October 29, 2026 19:56
@cknitt
cknitt enabled auto-merge (squash) October 29, 2026 19:56

@cristianoc cristianoc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me. Thanks for the fix.

@cknitt
cknitt merged commit d389139 into master Oct 29, 2026
26 checks passed
@cknitt
cknitt deleted the match-origin-9126 branch October 29, 2026 22:14
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.

Represent a let that destructures a GADT, and other synthesized matches, explicitly in the typed tree

2 participants