Skip to content

Make @unboxed apply to a whole variant only - #9040

Merged
cknitt merged 2 commits into
masterfrom
remove-per-constructor-unboxed
Oct 25, 2026
Merged

cknitt merged 2 commits into
masterfrom
remove-per-constructor-unboxed

Conversation

@cknitt

@cknitt cknitt commented Oct 25, 2026 •

Copy link
Copy Markdown
Member

Fixes #9038.

@unboxed on a single constructor made that constructor untagged and left the others tagged. Variants mixing the two never really worked:

Public code on GitHub uses the attribute either on every payload constructor or on the only constructor. Both mean the same as @unboxed on the type. None of it actually mixes tagged and untagged cases.

This PR makes @unboxed apply to a whole variant only:

  • The new error. @unboxed on a constructor is an error that says to write it on the type:
    @unboxed applies to a whole variant: write it on the type, as in `@unboxed type t = ...`, instead of on constructor A.
    
  • The single-field record case. For a constructor whose payload is an inline record with one field, moving the attribute changes the representation. @unboxed type t = A({x: int}) makes the value 1, the legacy unboxed representation, where the constructor form gave {x: 1}. The error says so and suggests a named record type to keep the object.
  • The layout of a variant is untagged exactly when its type is @unboxed.
  • Spec: rule 1 of compiler/ml/UntaggedVariants.md is rewritten.

A second commit fixes the error for a coercion between variants with different @unboxed configurations, which named the wrong variant as unboxed (snapshot variant_to_variant_coercion_unboxed). It was part of #9039, which this PR replaces.

Breaking: @unboxed on a constructor no longer compiles. A deprecation warning for 12.x will follow separately, so that libraries can move the attribute before upgrading.

New tests: fixtures unboxed_on_constructor.res and unboxed_on_single_field_record_constructor.res. The mixed-variant fixtures and untagged_mixed_test.res are removed. Rule 1 of the spec test now uses a type-level @unboxed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CMkrYaktctdKc4M41WF36t

Why:
    `@unboxed` on a single constructor made that constructor untagged and
    left the others tagged. Such mixed variants missed the checks of
    untagged ones (#9020) and interface and coercion checks (#9038), and
    their matches crashed or picked the wrong case. Public code uses the
    attribute on every payload constructor or on the only one, which the
    type-level `@unboxed` says, but no code mixes tagged and untagged cases.

`@unboxed` on a constructor is an error that says to write it on the type.
The layout of a variant is untagged exactly when its type is `@unboxed`, so
`check_invariant` no longer considers tagged and untagged blocks together,
the type's `@unboxed` is no longer copied onto constructors, and
`process_untagged`, a twin of `has_untagged`, is gone.

Diagnostic added (@unboxed on a constructor):
Example:
    type t = | @unboxed A(int) | B({x: int}) | C
Before (diagnostic): none; `A(3)` is `3` and `B` is `{TAG: "B", x: 1}`
After (diagnostic):
    @unboxed applies to a whole variant: write it on the type, as in `@unboxed type t = ...`, instead of on constructor A.

Diagnostic added (@unboxed on a single-field inline record constructor):
Example:
    type t = | @unboxed A({x: int})
Before (diagnostic): none; `A({x: 1})` is `{x: 1}`
After (diagnostic):
    @unboxed applies to a whole variant: write it on the type, as in `@unboxed type t = ...`, instead of on constructor A. For a constructor whose payload is an inline record with one field, `@unboxed type t = A({...})` makes the value that field. To keep the record object, give the record type a name: `@unboxed type t = A(r)`.

Breaking: `@unboxed` on a constructor no longer compiles. Moving it to the
type keeps the representation, except for a constructor whose payload is an
inline record with one field, whose value becomes the field; a named record
type keeps the object.

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

github-actions Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

Changelog

💥 Breaking Change

  • @unboxed applies to a whole variant and can no longer be written on a constructor. Write @unboxed type t = ... instead. A constructor whose payload is an inline record with one field became that record object, while @unboxed on its type makes the value the field: give the record type a name to keep the object.

Semantic Diff

2 commits · Full page

Make @unboxed apply to a whole variant only

cf19d26 · cknitt + Claude · Oct 25, 2026, 08:00 UTC

Why

@unboxed on a single constructor made that constructor untagged and left the others tagged. Such mixed variants missed the checks of untagged ones (#9020) and interface and coercion checks (#9038), and their matches crashed or picked the wrong case. Public code uses the attribute on every payload constructor or on the only one, which the type-level @unboxed says, but no code mixes tagged and untagged cases.

@unboxed on a constructor is an error that says to write it on the type. The layout of a variant is untagged exactly when its type is @unboxed, so check_invariant no longer considers tagged and untagged blocks together, the type's @unboxed is no longer copied onto constructors, and process_untagged, a twin of has_untagged, is gone.

Diagnostic added (@unboxed on a constructor)

type t = | @unboxed A(int) | B({x: int}) | C

diagnostic

-none; `A(3)` is `3` and `B` is `{TAG: "B", x: 1}`
+@unboxed applies to a whole variant: write it on the type, as in `@unboxed type t = ...`, instead of on constructor A.

Diagnostic added (@unboxed on a single-field inline record constructor)

type t = | @unboxed A({x: int})

diagnostic

-none; `A({x: 1})` is `{x: 1}`
+@unboxed applies to a whole variant: write it on the type, as in `@unboxed type t = ...`, instead of on constructor A. For a constructor whose payload is an inline record with one field, `@unboxed type t = A({...})` makes the value that field. To keep the record object, give the record type a name: `@unboxed type t = A(r)`.

⚠️ Breaking: @unboxed on a constructor no longer compiles. Moving it to the type keeps the representation, except for a constructor whose payload is an inline record with one field, whose value becomes the field; a named record type keeps the object.

✨ Name the unboxed variant correctly in the coercion error

6d9792a · cknitt + Claude · Oct 25, 2026, 08:38 UTC · new

The error for a coercion between variants with different @unboxed configurations said that the wrong one was unboxed.

Diagnostic modified (unboxed configurations)

@unboxed type x = One(bool) | Two
type y = One(bool) | Two

diagnostic

-- Variant x is not unboxed.
-- Variant y is unboxed.
+- Variant x is unboxed.
+- Variant y is not unboxed.

@codecov

codecov Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.77419% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 81.32%. Comparing base (5689a30) to head (6d9792a).

Files with missing lines Patch % Lines
compiler/ml/typedecl.ml 87.50% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #9040      +/-   ##
==========================================
- Coverage   81.33%   81.32%   -0.01%     
==========================================
  Files         456      456              
  Lines       64042    64036       -6     
==========================================
- Hits        52086    52077       -9     
- Misses      11956    11959       +3     
Files with missing lines Coverage Δ
compiler/ml/ast_untagged_variants.ml 88.54% <100.00%> (-0.50%) ⬇️
compiler/ml/printtyp.ml 70.55% <100.00%> (ø)
compiler/ml/variant_coercion.ml 92.39% <100.00%> (ø)
compiler/ml/variant_layout.ml 94.00% <100.00%> (-4.00%) ⬇️
compiler/ml/typedecl.ml 82.79% <87.50%> (+0.02%) ⬆️
🚀 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 25, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

rescript

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

@rescript/belt

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

@rescript/darwin-arm64

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

@rescript/linux-arm64

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

@rescript/linux-x64

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

@rescript/runtime

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

@rescript/win32-x64

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

commit: 6d9792a

@cknitt
cknitt marked this pull request as ready for review October 25, 2026 08:16
@cknitt

cknitt commented Oct 25, 2026

Copy link
Copy Markdown
Member Author

@codex review

@github-actions

Copy link
Copy Markdown

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 25, 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-25T08:55:47.552866Z 6d9792a 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. Keep them coming!

Reviewed commit: cf19d26e40

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

The error for a coercion between variants with different `@unboxed`
configurations said that the wrong one was unboxed.

Diagnostic modified (unboxed configurations):
Example:
    @unboxed type x = One(bool) | Two
    type y = One(bool) | Two
Before (diagnostic):
    - Variant x is not unboxed.
    - Variant y is unboxed.
After (diagnostic):
    - Variant x is unboxed.
    - Variant y is not unboxed.

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

cknitt commented Oct 25, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Another round soon, please!

Reviewed commit: 6d9792a5fd

ℹ️ 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 enabled auto-merge (squash) October 25, 2026 10:37

@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.

The commits' descriptions are so much better than the PR

@cknitt
cknitt merged commit a56f60f into master Oct 25, 2026
26 checks passed
@cknitt
cknitt deleted the remove-per-constructor-unboxed branch October 25, 2026 12:25
cknitt added a commit that referenced this pull request Oct 25, 2026
…#9015)

* Match untagged variants with a literal switch and one typeof dispatch

Why:
    The payload dispatch of an untagged variant ran its instance checks
    before the `typeof` checks, so strings and numbers reached `instanceof`:
    a class missing at run time threw for them, and a `Symbol.hasInstance`
    could claim them. Literals were tested by a size heuristic that chose
    between two equivalent expressions, the object case by a guard per
    instance case, and a subclass by negating it in its superclass's check.

The literals come first, compared with `===` by one `switch (x)` (an `if`
per body for at most two), whose default is the payload dispatch: one
`switch (typeof x)` over the primitive kinds, whose default is an `if` chain
over the instance cases (a subclass first), the function case, the tagged
constructors and the object case. In a complete match the last arm is not
checked. A literal without an arm skips the `switch` when no payload check
would accept it, and a `null` that only the object check would accept adds
`x !== null` to it. Checks leading to the default before the last checked
arm fold into its check, so class checks run only on objects.
`Dynamic_checks.is_a_literal_case` and the per-instance guards are gone.

JS modified (primitives before classes):
Example:
    @unboxed
    type t = Missing(missing) | Text(string) | Count(float)
    let kind = value =>
      switch value {
      | Missing(_) => "missing"
      | Text(text) => text
      | Count(_) => "count"
      }
Before (js):
    if (value instanceof UntaggedMissingClass) {
      return "missing";
    }
    switch (typeof value) {
      case "string" :
        return value;
      case "number" :
        return "count";
    }
After (js):
    switch (typeof value) {
      case "string" :
        return value;
      case "number" :
        return "count";
      default:
        return "missing";
    }

JS modified (instances without an arm):
Example:
    @unboxed
    type t = D(Date.t) | R(RegExp.t) | O({x: int}) | S(string) | I(int) | F(int => int)
    let kind = value =>
      switch value {
      | O(_) => "O"
      | S(_) => "S"
      | _ => "other"
      }
Before (js):
    if (value instanceof RegExp || value instanceof Date) {
      return "other";
    }
    switch (typeof value) {
      case "object" :
        return "O";
      case "string" :
        return "S";
      default:
        return "other";
    }
After (js):
    if (typeof value === "string") {
      return "S";
    } else if (typeof value === "object" && !(value instanceof RegExp) && !(value instanceof Date)) {
      return "O";
    } else {
      return "other";
    }

JS modified (a subclass first):
Example:
    @unboxed
    type upload = Blob(Blob.t) | File(File.t)
    let kind = upload =>
      switch upload {
      | Blob(_) => "blob"
      | File(_) => "file"
      }
Before (js):
    if (upload instanceof Blob && !(upload instanceof File)) {
      return "blob";
    } else {
      return "file";
    }
After (js):
    if (upload instanceof File) {
      return "file";
    } else {
      return "blob";
    }

Review: Does `break` in an arm leave the generated `switch` instead of the
loop? No: every arm placed in a generated `switch` is compiled in a switch
context, where `break` and `continue` are labeled;
`untagged_loop_control_test.res` checks arms of every part.

Review: What changes for a value outside every case? It lands in the last
arm of the chain: with `PPromise | PFloat` the float case is now checked, so
a thenable lands in `PPromise` (rule 10).

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

* Drop literal arms that a complete payload dispatch runs anyway

A complete payload dispatch with one body runs it unchecked, so a literal arm
with the same body needs no case of its own.

JS modified (literal arm equal to the payload arm):
Example:
    @unboxed
    type rec t = | @as(null) Null | @as(undefined) Undefined | WhyNotAnotherOne | Point({x: int, y: int})
    let plus = (x, y) =>
      switch y {
      | Null | Undefined => x
      | WhyNotAnotherOne => WhyNotAnotherOne
      | Point(_) => WhyNotAnotherOne
      }
Before (js):
    switch (y) {
      case null :
      case undefined :
        return x;
      case "WhyNotAnotherOne" :
        return "WhyNotAnotherOne";
      default:
        return "WhyNotAnotherOne";
    }
After (js):
    if (y == null) {
      return x;
    } else {
      return "WhyNotAnotherOne";
    }

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

* Update the gentype example for the untagged dispatch

The gentype example matches an untagged variant too; its output follows the
new dispatch.

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

* Say precisely when literals use an if and what folds

Rule 7 counts the literals that get a case, as the code does, and rule 11
names the values constant folding sees: literals and blocks of constants.

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

* Emit untagged checks directly instead of through Dynamic_checks

The check AST in Ast_untagged_variants only encoded the int-tag test of
tagged variants and the instance checks. Js_exp_make now builds both
directly (is_int_tag from the literal list, is_instance_of), and
matching_facts no longer carries has_null, has_undefined and
has_other_literal, which that test derived. A class is never a switch key,
so block_type_name no longer pretends to emit one. No output changes.

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

* Share which literal passes which payload check

Code generation and constant folding each had a table of the literals a
payload case accepts, and they disagreed on null and an object case.
Variant_runtime.literal_passes is now the one table; folding follows the
generated code, where `typeof null` is "object", so null without a null
case folds into the object case as it is matched at run time.

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

* Keep the checks an empty arm or a tagged block needs

Why:
    The payload dispatch decided that a match had no arm besides its
    primitive ones from the compiled chain of the others, which is empty when
    their bodies do nothing, and then ran the last primitive arm unchecked.
    A literal arm sharing the body of the payload dispatch was dropped,
    although two tagged blocks are told apart by reading their tag, which a
    literal such as `null` does not have.

Whether there are other arms comes from the dispatch entries. A literal arm
joins the payload dispatch only when at most one tagged block is matched, and
the last tag of a complete match is not checked, as the last arm of every
other complete match.

JS modified (an arm that does nothing):
Example:
    @unboxed type t = S(string) | O({x: int})
    let f = v => {
      switch v {
      | S(_) => Console.log("s")
      | O(_) => ()
      }
      Console.log("after")
    }
Before (js):
    switch (typeof v) {
      default:
        console.log("s");
    }
    console.log("after");
After (js):
    if (typeof v === "string") {
      console.log("s");
    }
    console.log("after");

JS modified (a literal sharing the body of tagged blocks):
Example:
    type t = | @unboxed MS(string) | MB({x: int}) | MC(int) | @as(null) MNull | @as(1) MOne
    let f = v =>
      switch v {
      | MB(_) => "other"
      | MC(_) => "other"
      | MOne => "one"
      | MNull | MS(_) => "other"
      }
Before (js):
    if (v === 1) {
      return "one";
    }
    if (typeof v === "string") {
      return "other";
    }
    switch (v.TAG) {
      case "MB" :
      case "MC" :
        return "other";
    }
After (js):
    if (v === null) {
      return "other";
    }
    if (v === 1) {
      return "one";
    }
    if (typeof v === "string") {
      return "other";
    }
    switch (v.TAG) {
      case "MB" :
      default:
        return "other";
    }

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

* Drop the tagged blocks of mixed variants from the dispatch

Since #9040 a variant is untagged as a whole or not at all, so the payload
dispatch of an untagged variant never meets a tagged block. The `switch` on
the tag inside its `if` chain, the merging of tagged clauses, the count of
tagged blocks before dropping literal arms and the tag-name parameter are
gone, and rule 8 no longer lists tagged constructors.

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

* Give a literal a case where skipping would add a check

Why:
    A literal without an arm skipped the literal switch whenever the payload
    dispatch led it to the default, even where that made a complete
    dispatch check its last arm or added `x !== null`, although the switch
    existed for other literals anyway.

When other literals get a case, a literal whose skipping would add such a
check gets a case too. Literals that reach the default without a new check
still skip. The fallback for tagged blocks is gone: an untagged variant has
none since #9040. Rule 10 now says where a value outside every case lands
when the match shares a default.

Generated code changed (a bool literal beside string literals):
Example:
    @unboxed
    type t = | @as("a") A | @as("b") B | @as(true) T | S(string) | O({x: int})
    let f = (x: t) => switch x { | S(_) => 1 | O(_) => 2 | _ => 3 }
Before (JS):
    switch (x) {
      case "a" :
      case "b" :
        return 3;
      default:
        if (typeof x === "string") {
          return 1;
        } else if (typeof x === "object") {
          return 2;
        } else {
          return 3;
        }
    }
After (JS):
    switch (x) {
      case "a" :
      case "b" :
      case true :
        return 3;
      default:
        if (typeof x === "string") {
          return 1;
        } else {
          return 2;
        }
    }

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

* Let literals skip the switch whatever other literals do

Why:
    Giving a literal a case where skipping would add a check saves one
    typeof or null check in a narrow situation, at the price of two more
    predicates in the most intricate part of the dispatch.

This undoes that rule, keeping the removal of the tagged-block fallback and
the rule 10 clause from the same commit.

Generated code changed (a bool literal beside string literals):
Example:
    @unboxed
    type t = | @as("a") A | @as("b") B | @as(true) T | S(string) | O({x: int})
    let f = (x: t) => switch x { | S(_) => 1 | O(_) => 2 | _ => 3 }
Before (JS):
    switch (x) {
      case "a" :
      case "b" :
      case true :
        return 3;
      default:
        if (typeof x === "string") {
          return 1;
        } else {
          return 2;
        }
    }
After (JS):
    switch (x) {
      case "a" :
      case "b" :
        return 3;
      default:
        if (typeof x === "string") {
          return 1;
        } else if (typeof x === "object") {
          return 2;
        } else {
          return 3;
        }
    }

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

* Regenerate the callable-instance test for the typeof dispatch

Why:
    The test added to the bottom of the stack is compiled by this pull
    request's dispatch, which checks the function arm against the classes.

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

---------

Signed-off-by: Christoph Knittel <ck@cca.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
cknitt added a commit that referenced this pull request Oct 26, 2026
* Deprecate @unboxed on a variant constructor

Why:
    v13 rejects @unboxed on a constructor (#9040): @unboxed applies to a
    whole variant. 12.x users should hear about it before upgrading.

`@unboxed` written on a constructor warns (warning 3), before the type's
attributes are copied to its constructors, so a type-level `@unboxed` does
not warn.

Diagnostic added (@unboxed on a constructor):
Example:
    type mixed = | @unboxed Text(string) | Count(int)
    @unboxed
    type whole = | @unboxed Name(string) | Age(int)
Before (diagnostic): none
After (diagnostic):
    Warning number 3
    deprecated: @unboxed on constructor Text is deprecated and will be an
    error in v13, where @unboxed applies to a whole variant: write it on the
    type, as in `@unboxed type t = ...`.
    Warning number 3
    deprecated: @unboxed on constructor Name is deprecated and will be an
    error in v13. The type is already @unboxed, so remove it from the
    constructor.

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

* Add PR link to changelog entry

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

* Skip spread constructors in the @unboxed constructor deprecation

Why:
    A variant spread copies the source constructors with their attributes,
    including the source type's `@unboxed`, so the spread site warned about
    an annotation the user never wrote, or repeated the source's warning.

The deprecation now skips constructors marked as coming from a spread;
`@unboxed` written on a constructor is reported once, where it is written.

Diagnostic removed (@unboxed reported at a spread):
Example:
    @unboxed
    type a = A(string)
    @unboxed
    type b = | ...a | B(int)
Before (diagnostic):
    Warning number 3
    1:1-8
    deprecated: @unboxed on constructor A is deprecated and will be an
    error in v13. The type is already @unboxed, so remove it from the
    constructor.
After (diagnostic): none

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

---------

Signed-off-by: Christoph Knittel <ck@cca.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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.

Per-constructor @unboxed is not compared across interfaces or coercions

2 participants