Skip to content

Resolve untagged declaration shapes and literals once - #9017

Open
cknitt wants to merge 5 commits into
untagged-dispatchfrom
untagged-declarations
Open

cknitt wants to merge 5 commits into
untagged-dispatchfrom
untagged-declarations

Conversation

@cknitt

@cknitt cknitt commented Oct 24, 2026

Copy link
Copy Markdown
Member

Stacked on #9015. Declaration-side follow-ups from the architecture review of the untagged variant work, one commit each:

  1. Builtin types state their shapes.
    • Predef declares int, float, bigint, string, bool, dict, array and promise with the type_shape an annotation would give them. The list of builtin paths in get_block_type_from_typ is gone.
    • @typeOf("number") type count = int is now a restatement.
    • @typeOf("string") type t = int is now reported as a contradiction.
    • An interface's @typeOf("number") type t accepts type t = int.
  2. The literal of a constructor without @as is resolved once.
    • The layout stores it: the name, or 0 for []. Every reader takes it as is, with no None case each.
    • Variant_runtime.matchable_tag and to_matchable_tag are gone.
    • Messages that talk about the declared @as read the constructor's annotation.
    • As a consequence, A and @as("A") A agree across an interface and in a coercion, and constant folding compares any two constructors by runtime value.
    • No JavaScript output changes.
  3. @notUndefined on type abbreviations.
    • It follows the rule of @instanceOf/@typeOf: it can refine a type whose representation the compiler doesn't see, or restate that a type can't be undefined. A restatement keeps the payload kind, so @notUndefined type count = int is still a number case.
    • Interface inclusion and the declaration now share one check, Typeopt.undefinedness. For that, get_unboxed_type_representation moves from Typedecl to Ctype.

The spec (compiler/ml/UntaggedVariants.md) is updated in rules 2, 4, 5, 13 and 14.

New tests:

  • untagged_builtin_alias_test.res: an annotation restating int, and an interface stating @typeOf("number") over int.
  • as_constructor_name_test.res: coercion and interface inclusion between A and @as("A") A.
  • not_undefined_abbreviation_test.res: a refined abstract type isn't boxed in option, and a restated builtin keeps its kind.
  • ounit_lambda_constant_tests.ml: constructors compare by runtime value when folded.
  • Fixtures:
    • runtime_annotation_contradicts_builtin.res;
    • not_undefined_on_option.res;
    • not_undefined_on_functor_parameter_type.res;
    • invalid_attribute_not_undefined.res, now a record, since @notUndefined type t = int is valid.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CMkrYaktctdKc4M41WF36t

@cknitt
cknitt added this pull request to stack #9016 October 24, 2026 10:46
@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

Changelog

💅 Polish

  • Allow @notUndefined on type abbreviations, and @typeOf restating a builtin type's kind, as in @typeOf("number") type count = int; a constructor A and @as("A") A now agree across interfaces and coercions.

Semantic Diff

5 commits · Full page

Let builtin types state their runtime shapes

2271e3e · cknitt + Claude · Oct 24, 2026, 10:30 UTC

Why

Payload kinds had two sources: the shapes declarations state, and a list of builtin paths in get_block_type_from_typ. Builtins were outside the generic checks, so an annotation could not restate a builtin's kind and an interface could not state it over a builtin implementation.

The declarations of int, float, bigint, string, bool, dict, array and promise in Predef state their shapes, and only tuples and function types are classified structurally. @typeOf("number") type count = int is a restatement, @typeOf("string") type t = int a contradiction, and an interface's @typeOf("number") type t accepts type t = int.

Diagnostic modified (annotation over a builtin)

@typeOf("string") type t = int

diagnostic

-@typeOf on a type abbreviation must describe an abstract type, or a type with the same annotation. The abbreviated type already has a runtime representation the compiler knows, or can be undefined.
+This annotation, @typeOf("string"), contradicts the abbreviated type, whose values are recognized by @typeOf("number").

Diagnostic modified (contradicting annotation)

@typeOf("symbol") type sym
@typeOf("number") type t = sym

diagnostic

-This annotation, @typeOf("number"), contradicts the abbreviated type's annotation, @typeOf("symbol").
+This annotation, @typeOf("number"), contradicts the abbreviated type, whose values are recognized by @typeOf("symbol").
Resolve the literal of a constructor without @as once

d1446fb · cknitt + Claude · Oct 24, 2026, 10:40 UTC

Why

"A constructor without @as is its name" was re-derived wherever a tag was read: in the matching facts, constant folding, code generation, the duplicate-literal check, coercions and gentype, each with its own None case, and [] special-cased in several of them.

The layout stores every constructor's literal, the name when there is no @as and 0 for []. Readers take it as is, and matching compares tags without the widening to an optional literal. Messages that name the declared @as read the constructor's annotation. Since the literal is what the value is, A and @as("A") A now agree across an interface and in a coercion, and constant folding compares any two constructors by their runtime values.

Representation modified (constructor tag in the layout)

type t = A | @as("b") B

layout

-A -> {name: "A", literal: None}
-B -> {name: "B", literal: Some (String "b")}
+A -> {name: "A", literal: String "A"}
+B -> {name: "B", literal: String "b"}

Review

  • Does this change generated JavaScript? No snapshot changed: the resolved literal is the value every reader computed before.
Allow @notUndefined on type abbreviations

7fb8da3 · cknitt + Claude · Oct 24, 2026, 10:46 UTC

Why

@instanceOf and @typeOf can refine or restate the type an abbreviation stands for, while @notUndefined was rejected on every abbreviation, even of an abstract type it could refine.

@notUndefined on an abbreviation follows the rule of the other annotations: it can refine a type whose representation the compiler does not see, or restate that the type cannot be undefined, keeping a payload kind the type has. The check whether a type can be undefined is shared with interface inclusion (Typeopt.undefinedness); get_unboxed_type_representation moves to Ctype so that Typedecl can call it.

Diagnostic modified (@notUndefined on an abbreviation)

@notUndefined
type t = option<int>

diagnostic

-@notUndefined can only be used on abstract types
+@notUndefined on a type abbreviation must describe an abstract type, or a type with the same annotation. The abbreviated type already has a runtime representation the compiler knows, or can be undefined.

Diagnostic modified (@notUndefined on a record)

@notUndefined
type t = {x: int}

diagnostic

-@notUndefined can only be used on abstract types
+@notUndefined can only be used on abstract types and type abbreviations, whose runtime representation it describes.

JS modified (refined abstract type)

type opaque
@notUndefined type refined = opaque
let wrap = (x: refined) => Some(x)

js

-error
+function wrap(x) {
+  return x;
+}
Fold matches of constructors by their runtime literals

d42a132 · cknitt + Claude · Oct 24, 2026, 12:02 UTC

Why

A coercion accepts constructors whose literals are spelled differently but are the same value, such as @as(1.) for @as(1.0), while a match on a constant constructor was folded by comparing tags structurally, so the coerced constant missed its arm.

Constant constructors and tagged blocks are now found by the runtime value of their literal, as the untagged dispatch already does.

JS modified (folded match of a coerced constant)

type source = | @as(1.) One
type target = | @as(1.0) One | Two | Three | Four
let isOne = (x: target) =>
  switch x {
  | One => true
  | _ => false
  }
let r = isOne((One: source :> target))

js

-let r = false;
+let r = true;
Fold matches of numeric constants by their runtime value

f42bd93 · cknitt + Claude · Oct 24, 2026, 16:46 UTC

Why

A constant constructor represented by an int is lowered to an int, and a match on it was folded by comparing with the int tags of the matched variant only. A coercion accepts @as(1) for @as(1.), so the coerced constant missed its arm.

The int is compared with every constant's literal by value.

JS modified (folded match of a coerced int constant)

type source = | @as(1) One
type target = | @as(1.) One | Two | Three | Four
let isOne = (x: target) =>
  switch x {
  | One => true
  | _ => false
  }
let r = isOne((One: source) :> target)

js

-let r = false;
+let r = true;

@codecov

codecov Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.32117% with 16 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.30%. Comparing base (77e7004) to head (f42bd93).

Files with missing lines Patch % Lines
compiler/ml/lambda.ml 76.92% 6 Missing ⚠️
compiler/ml/ctype.ml 76.47% 4 Missing ⚠️
compiler/core/lam_compile.ml 71.42% 2 Missing ⚠️
compiler/gentype/translate_type_declarations.ml 91.66% 1 Missing ⚠️
compiler/ml/error_message_utils.ml 50.00% 1 Missing ⚠️
compiler/ml/typeopt.ml 87.50% 1 Missing ⚠️
compiler/ml/variant_layout.ml 83.33% 1 Missing ⚠️
Additional details and impacted files
@@                Coverage Diff                 @@
##           untagged-dispatch    #9017   +/-   ##
==================================================
  Coverage              81.30%   81.30%           
==================================================
  Files                    456      456           
  Lines                  64220    64210   -10     
==================================================
- Hits                   52211    52208    -3     
+ Misses                 12009    12002    -7     
Files with missing lines Coverage Δ
compiler/core/js_dump.ml 93.11% <100.00%> (-0.02%) ⬇️
compiler/core/lam_compile_const.ml 96.42% <100.00%> (-0.13%) ⬇️
compiler/ml/ast_untagged_variants.ml 85.76% <100.00%> (+0.05%) ⬆️
compiler/ml/includemod.ml 76.48% <100.00%> (+0.18%) ⬆️
compiler/ml/matching.ml 78.14% <100.00%> (ø)
compiler/ml/predef.ml 100.00% <100.00%> (ø)
compiler/ml/printtyp.ml 70.52% <100.00%> (-0.04%) ⬇️
compiler/ml/typedecl.ml 83.00% <100.00%> (+0.20%) ⬆️
compiler/ml/variant_coercion.ml 91.95% <100.00%> (-0.44%) ⬇️
compiler/ml/variant_runtime.ml 93.44% <100.00%> (-0.31%) ⬇️
... and 8 more
🚀 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.

@cknitt
cknitt marked this pull request as ready for review October 24, 2026 11:47
@cknitt

cknitt commented Oct 24, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 24, 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-24T16:48:40.365774Z 7cb6fa9 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 chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a16ad0b18f

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

Comment thread compiler/ml/typedecl.ml
Comment thread compiler/ml/variant_coercion.ml
@pkg-pr-new

pkg-pr-new Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

rescript

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

@rescript/belt

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

@rescript/darwin-arm64

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

@rescript/linux-arm64

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

@rescript/linux-x64

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

@rescript/runtime

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

@rescript/win32-x64

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

commit: f42bd93

@cknitt

cknitt commented Oct 24, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 899176311f

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

Comment thread compiler/ml/lambda.ml Outdated
@cknitt

cknitt commented Oct 24, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Bravo.

Reviewed commit: 7cb6fa9c15

ℹ️ 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 and others added 5 commits October 24, 2026 20:20
Why:
    Payload kinds had two sources: the shapes declarations state, and a list
    of builtin paths in get_block_type_from_typ. Builtins were outside the
    generic checks, so an annotation could not restate a builtin's kind and
    an interface could not state it over a builtin implementation.

The declarations of `int`, `float`, `bigint`, `string`, `bool`, `dict`,
`array` and `promise` in `Predef` state their shapes, and only tuples and
function types are classified structurally. `@typeOf("number") type count =
int` is a restatement, `@typeOf("string") type t = int` a contradiction, and
an interface's `@typeOf("number") type t` accepts `type t = int`.

Diagnostic modified (annotation over a builtin):
Example:
    @typeof("string") type t = int
Before (diagnostic):
    @typeof on a type abbreviation must describe an abstract type, or a type with the same annotation. The abbreviated type already has a runtime representation the compiler knows, or can be undefined.
After (diagnostic):
    This annotation, @typeof("string"), contradicts the abbreviated type, whose values are recognized by @typeof("number").

Diagnostic modified (contradicting annotation):
Example:
    @typeof("symbol") type sym
    @typeof("number") type t = sym
Before (diagnostic):
    This annotation, @typeof("number"), contradicts the abbreviated type's annotation, @typeof("symbol").
After (diagnostic):
    This annotation, @typeof("number"), contradicts the abbreviated type, whose values are recognized by @typeof("symbol").

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>
Why:
    "A constructor without @as is its name" was re-derived wherever a tag
    was read: in the matching facts, constant folding, code generation, the
    duplicate-literal check, coercions and gentype, each with its own
    `None` case, and `[]` special-cased in several of them.

The layout stores every constructor's literal, the name when there is no
`@as` and `0` for `[]`. Readers take it as is, and matching compares tags
without the widening to an optional literal. Messages that name the declared
`@as` read the constructor's annotation. Since the literal is what the value
is, `A` and `@as("A") A` now agree across an interface and in a coercion, and
constant folding compares any two constructors by their runtime values.

Representation modified (constructor tag in the layout):
Example:
    type t = A | @as("b") B
Before (layout):
    A -> {name: "A", literal: None}
    B -> {name: "B", literal: Some (String "b")}
After (layout):
    A -> {name: "A", literal: String "A"}
    B -> {name: "B", literal: String "b"}

Review: Does this change generated JavaScript? No snapshot changed: the
resolved literal is the value every reader computed before.

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>
Why:
    `@instanceOf` and `@typeOf` can refine or restate the type an
    abbreviation stands for, while `@notUndefined` was rejected on every
    abbreviation, even of an abstract type it could refine.

`@notUndefined` on an abbreviation follows the rule of the other
annotations: it can refine a type whose representation the compiler does
not see, or restate that the type cannot be undefined, keeping a payload
kind the type has. The check whether a type can be undefined is shared with
interface inclusion (`Typeopt.undefinedness`); `get_unboxed_type_representation`
moves to `Ctype` so that `Typedecl` can call it.

Diagnostic modified (@notundefined on an abbreviation):
Example:
    @notundefined
    type t = option<int>
Before (diagnostic):
    @notundefined can only be used on abstract types
After (diagnostic):
    @notundefined on a type abbreviation must describe an abstract type, or a type with the same annotation. The abbreviated type already has a runtime representation the compiler knows, or can be undefined.

Diagnostic modified (@notundefined on a record):
Example:
    @notundefined
    type t = {x: int}
Before (diagnostic):
    @notundefined can only be used on abstract types
After (diagnostic):
    @notundefined can only be used on abstract types and type abbreviations, whose runtime representation it describes.

JS modified (refined abstract type):
Example:
    type opaque
    @notundefined type refined = opaque
    let wrap = (x: refined) => Some(x)
Before (js): error
After (js):
    function wrap(x) {
      return x;
    }

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>
Why:
    A coercion accepts constructors whose literals are spelled differently
    but are the same value, such as `@as(1.)` for `@as(1.0)`, while a match
    on a constant constructor was folded by comparing tags structurally, so
    the coerced constant missed its arm.

Constant constructors and tagged blocks are now found by the runtime value
of their literal, as the untagged dispatch already does.

JS modified (folded match of a coerced constant):
Example:
    type source = | @as(1.) One
    type target = | @as(1.0) One | Two | Three | Four
    let isOne = (x: target) =>
      switch x {
      | One => true
      | _ => false
      }
    let r = isOne((One: source :> target))
Before (js): let r = false;
After (js): let r = true;

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>
Why:
    A constant constructor represented by an int is lowered to an int, and
    a match on it was folded by comparing with the int tags of the matched
    variant only. A coercion accepts `@as(1)` for `@as(1.)`, so the coerced
    constant missed its arm.

The int is compared with every constant's literal by value.

JS modified (folded match of a coerced int constant):
Example:
    type source = | @as(1) One
    type target = | @as(1.) One | Two | Three | Four
    let isOne = (x: target) =>
      switch x {
      | One => true
      | _ => false
      }
    let r = isOne((One: source) :> target)
Before (js): let r = false;
After (js): let r = true;

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 force-pushed the untagged-declarations branch from 7cb6fa9 to f42bd93 Compare October 24, 2026 20:20

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.

1 participant