Skip to content

Test primitive untagged cases before class checks - #8967

Draft
cknitt wants to merge 2 commits into
not-undefined-inclusionfrom
untagged-typeof-first
Draft

cknitt wants to merge 2 commits into
not-undefined-inclusionfrom
untagged-typeof-first

Conversation

@cknitt

@cknitt cknitt commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

Stacked on #8966. Follow-up from the risk assessment of #8950 (section 4).

A switch on an untagged variant ran its class checks before its typeof switch, so strings and numbers reached v instanceof C too. A class missing where the code runs, such as a user class, or Intl.Segmenter on an older engine, then threw ReferenceError/TypeError for every value, the primitive cases included.

When a variant has class cases, the arms of primitive kinds (string, number, bigint, boolean, symbol) are now tested first, in an outer switch (typeof v). Its default holds the class checks and the remaining object/function arms:

switch (typeof v) {
  case "string" :
    return v;
  case "number" :
    return "F";
  default:
    if (v instanceof Nope) {
      return "N";
    }
    if (Array.isArray(v)) {
      return "A";
    }
}

A class instance is never a primitive, so no value changes arm. Two details:

  • Arms that share a body through fall-through are now grouped only within one of the two switches.
  • Variants with only an array case keep the old form, because Array.isArray cannot throw.

Symbol.hasInstance: a class whose hook accepts a primitive no longer wins over a case of that primitive kind. The spec now trusts Symbol.hasInstance only for objects and functions. The values of an @instanceOf type are instances, so such a primitive already contradicts its annotation.

Cost: variants with class cases and object or function arms get a second switch (typeof v), and a body shared by a primitive and an object arm is emitted twice, as in awaitUser in UntaggedVariants.mjs. The two-arm form is unchanged: it still checks the arm declared first.

UntaggedVariants.md rules 8 and 10 describe the new order, and the item is off the "still open" list.

New tests in untagged_runtime_annotations_test.res: a missing class beside string and number cases, and a default shared by a primitive and an object case.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Fc7LEuyEfynjaKCVEBPmTF

@cknitt
cknitt added this pull request to stack #8968 October 22, 2026 19:15
@github-actions

Copy link
Copy Markdown

Semantic Diff

1 commit · Full page

Test primitive untagged cases before class checks

24e4412 · Christoph Knittel with Claude Opus 5.5 · Oct 22, 2026

Why

A switch on an untagged variant ran its class checks before the typeof switch, so strings and numbers reached v instanceof C too. A class missing where the code runs, such as a user class or a newer Intl class, then threw for every value, even for the primitive cases.

When a variant has class cases, the arms of primitive kinds (string, number, bigint, boolean, symbol) are now tested first, in an outer switch (typeof v) whose default holds the class checks and the remaining object/function arms. A class instance is never a primitive, so no value changes arm. Primitive and other arms no longer share a body through fall-through, since they sit in different switches. Variants with only an array case keep the old form, since Array.isArray cannot throw.

JS modified (a class case beside primitive cases)

@instanceOf("Nope") type nope
@unboxed type t = N(nope) | S(string) | F(float) | A(array<int>)

js

-if (v instanceof Nope) {
-  return "N";
-}
-if (Array.isArray(v)) {
-  return "A";
-}
 switch (typeof v) {
   case "string" :
     return v;
   case "number" :
     return "F";
+  default:
+    if (v instanceof Nope) {
+      return "N";
+    }
+    if (Array.isArray(v)) {
+      return "A";
+    }
 }

Review

  • What does it cost? A second switch (typeof v) when there are object or function arms too, and a body shared by a primitive and an object arm is emitted twice (awaitUser in UntaggedVariants.mjs).

@cknitt

cknitt commented Oct 22, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 22, 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-23T05:22:16.693450Z 2e15e8c 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: 24e441242e

ℹ️ 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/core/lam_compile.ml
@pkg-pr-new

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

Copy link
Copy Markdown

Open in StackBlitz

rescript

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

@rescript/belt

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

@rescript/darwin-arm64

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

@rescript/linux-arm64

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

@rescript/linux-x64

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

@rescript/runtime

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

@rescript/win32-x64

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

commit: 2e15e8c

Why:
    A `switch` on an untagged variant ran its class checks before the
    `typeof` switch, so strings and numbers reached `v instanceof C` too. A
    class missing where the code runs, such as a user class or a newer
    `Intl` class, then threw for every value, even for the primitive cases.

When a variant has class cases, the arms of primitive kinds (string,
number, bigint, boolean, symbol) are now tested first, in an outer
`switch (typeof v)` whose default holds the class checks and the remaining
`object`/`function` arms. A class instance is never a primitive, so no value
changes arm. Primitive and other arms no longer share a body through
fall-through, since they sit in different switches. Variants with only an
array case keep the old form, since `Array.isArray` cannot throw.

JS modified (a class case beside primitive cases):
Example:
    @instanceof("Nope") type nope
    @unboxed type t = N(nope) | S(string) | F(float) | A(array<int>)
Before (js):
    if (v instanceof Nope) {
      return "N";
    }
    if (Array.isArray(v)) {
      return "A";
    }
    switch (typeof v) {
      case "string" :
        return v;
      case "number" :
        return "F";
    }
After (js):
    switch (typeof v) {
      case "string" :
        return v;
      case "number" :
        return "F";
      default:
        if (v instanceof Nope) {
          return "N";
        }
        if (Array.isArray(v)) {
          return "A";
        }
    }

Review: What does it cost? A second `switch (typeof v)` when there are
object or function arms too, and a body shared by a primitive and an object
arm is emitted twice (`awaitUser` in `UntaggedVariants.mjs`).

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>
@cknitt
cknitt force-pushed the untagged-typeof-first branch from 24e4412 to 4154702 Compare October 22, 2026 19:29
@github-actions

Copy link
Copy Markdown

Semantic Diff

1 commit · Full page

Test primitive untagged cases before class checks

4154702 · Christoph Knittel with Claude Opus 5.5 · Oct 22, 2026

Why

A switch on an untagged variant ran its class checks before the typeof switch, so strings and numbers reached v instanceof C too. A class missing where the code runs, such as a user class or a newer Intl class, then threw for every value, even for the primitive cases.

When a variant has class cases, the arms of primitive kinds (string, number, bigint, boolean, symbol) are now tested first, in an outer switch (typeof v) whose default holds the class checks and the remaining object/function arms. A class instance is never a primitive, so no value changes arm. Primitive and other arms no longer share a body through fall-through, since they sit in different switches. Variants with only an array case keep the old form, since Array.isArray cannot throw.

JS modified (a class case beside primitive cases)

@instanceOf("Nope") type nope
@unboxed type t = N(nope) | S(string) | F(float) | A(array<int>)

js

-if (v instanceof Nope) {
-  return "N";
-}
-if (Array.isArray(v)) {
-  return "A";
-}
 switch (typeof v) {
   case "string" :
     return v;
   case "number" :
     return "F";
+  default:
+    if (v instanceof Nope) {
+      return "N";
+    }
+    if (Array.isArray(v)) {
+      return "A";
+    }
 }

Review

  • What does it cost? A second switch (typeof v) when there are object or function arms too, and a body shared by a primitive and an object arm is emitted twice (awaitUser in UntaggedVariants.mjs).

@codecov

codecov Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.71429% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.22%. Comparing base (df024f4) to head (2e15e8c).

Files with missing lines Patch % Lines
compiler/core/lam_compile.ml 85.71% 3 Missing ⚠️
Additional details and impacted files
@@                   Coverage Diff                    @@
##           not-undefined-inclusion    #8967   +/-   ##
========================================================
  Coverage                    81.22%   81.22%           
========================================================
  Files                          457      457           
  Lines                        63997    64016   +19     
========================================================
+ Hits                         51982    51998   +16     
- Misses                       12015    12018    +3     
Files with missing lines Coverage Δ
compiler/core/lam_compile.ml 90.10% <85.71%> (-0.15%) ⬇️
🚀 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.

Why:
    With the primitive cases tested first, a class whose
    `Symbol.hasInstance` accepts a primitive no longer wins over a case of
    that primitive kind, while the spec still said `Symbol.hasInstance` is
    trusted without qualification.

Rule 8 now says that `Symbol.hasInstance` is trusted for objects and
functions, and that a primitive whose kind is a case lands in that case
first. The values of an `@instanceOf` type are instances, so a primitive of
that type already contradicts its annotation.

Representation modified (a primitive accepted by Symbol.hasInstance):
Example:
    %%raw(`globalThis.Even = class { static [Symbol.hasInstance](x) { return x % 2 === 0 } }`)
    @instanceof("Even") type even
    @unboxed type t = E(even) | N(float) | S(string)
Before (representation): 4 ↦ E
After (representation): 4 ↦ N

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

Semantic Diff

1 new commit (2 total) · Full page

Trust Symbol.hasInstance only for objects and functions

2e15e8c · Christoph Knittel with Claude Opus 5.5 · Oct 23, 2026

Why

With the primitive cases tested first, a class whose Symbol.hasInstance accepts a primitive no longer wins over a case of that primitive kind, while the spec still said Symbol.hasInstance is trusted without qualification.

Rule 8 now says that Symbol.hasInstance is trusted for objects and functions, and that a primitive whose kind is a case lands in that case first. The values of an @instanceOf type are instances, so a primitive of that type already contradicts its annotation.

Representation modified (a primitive accepted by Symbol.hasInstance)

%%raw(`globalThis.Even = class { static [Symbol.hasInstance](x) { return x % 2 === 0 } }`)
@instanceOf("Even") type even
@unboxed type t = E(even) | N(float) | S(string)

representation

-4 ↦ E
+4 ↦ N

@cknitt

cknitt commented Oct 23, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: 2e15e8c621

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

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