Skip to content

Declare the runtime class or typeof of types, and annotate the stdlib - #8950

Open
cknitt wants to merge 11 commits into
masterfrom
instanceof-typeof-annotations
Open

cknitt wants to merge 11 commits into
masterfrom
instanceof-typeof-annotations

Conversation

@cknitt

@cknitt cknitt commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

Fixes #8938. Includes the former #8948 (stdlib annotations) and #8949 (removal of the name list), so the spec in compiler/ml/UntaggedVariants.md (#8952) never describes an intermediate state. Each commit updates the rules it changes.

Untagged variants recognized payloads of abstract types only through a hard-coded list of stdlib type names (Stdlib_Date.t, Stdlib.Map.t, …). The list was closed to bindings such as WebAPI's (#7147: CanvasGradient, CanvasPattern, …) and knew no subclasses, so a Blob case declared before a File case also caught files (#8938).

This PR lets a type declaration state how its values are recognized at run time:

@instanceOf("Blob") type blob
@instanceOf("File") type file = private blob   // a subclass
@instanceOf("Intl.Locale") type locale         // a global path
@typeOf("symbol") type key                     // string, number, bigint, boolean, symbol, function or object

@unboxed
type part = Blob(blob) | File(file) | Key(key) | Text(string)
if (x instanceof Blob && !(x instanceof File)) { … }
if (x instanceof File) { … }
switch (typeof x) { case "symbol": … case "string": … }

The mechanism

  • Where: on abstract types and type abbreviations, public or private. Public abbreviations are needed for the typed arrays, which are aliases of TypedArray.t<'a> that can only be told apart by the name as written.
  • Stored shape: when a type is declared, its annotation is resolved into a shape stored on the declaration (type_shape): Recognized_as a class with its superclasses or a typeof kind, Never_undefined for @notUndefined, or Unstated. The classifier, option unboxing and interface inclusion read the shape of the first type on an expansion chain that states one, so the outermost annotation decides. A shape is resolved in the environment its type is declared in, so an interface's shape is what its users see.
  • Abbreviations must agree with the abbreviated type. An annotation can refine an abstract, non-builtin type without an annotation, or repeat the abbreviated type's annotation. A subclass needs a private abbreviation, or a public one that is a strict instance of the superclass type after expansion (a phantom hierarchy such as type element<'a> = node<_element<'a>>), and no class may appear twice in a chain. A class can also be declared over a @typeOf("object") type. The type of a functor parameter, of a recursive module being defined, or a locally abstract type stands for another type and cannot be refined. So @typeOf("string") type s = option<string>, @instanceOf("Dog") type dog = animal and @instanceOf("Date") type d = X.t in a functor are errors.
  • Inheritance: a class check excludes the subclasses that are cases of the same variant, so neither declaration order nor the cases a switch matches change which case a value lands in. The object and function checks exclude every class case. A private abbreviation also gives :> upcasts.
  • undefined: both annotations imply that a value is never undefined, as @notUndefined does, so option stays unboxed, also for @unboxed wrappers of such a type. @notUndefined stays for types whose values have no single class or typeof, such as Jsx.element.
  • Interfaces and functors: when an interface states a class or typeof for a type, the implementation must state the same one, superclasses included; an interface that states none hides it. The payload kind of every untagged case the interface knows is compared too. This covers .resi files, functor arguments, with type and module type ascriptions.
  • Shadowing: a class check reads the global through globalThis when a binding of the same name is in scope (Read JS globals through globalThis wherever a same-named binding is in scope #8785). Dotted paths are emitted as property accesses on the first segment.
  • Validation: an invalid class path (including a JS keyword global), an unknown typeof, both annotations or one twice on a type, or an annotation on a record or variant are errors. So are paths that name no class to recognize values by: globalThis.C, Object, Function, Array, the primitive wrappers (String, Number, Boolean, BigInt, Symbol) and non-constructors (Math, JSON, Intl, …). A variant can have at most one case per class and one symbol case.
  • No names: payloads are recognized only by their annotation or by the builtin type they expand to (string, int, float, bigint, bool, dict, array, tuples, promise, functions). The list of 21 stdlib type names and the name-based dict check are gone; a local type named dict is no longer taken for an object.
  • Editor: completion offers both annotations. @notUndefined was listed with the file-level @@ decorators; it now completes after a single @.

The stdlib

The stdlib's class-backed types declare their class instead of only @notUndefined:

  • Classes: ArrayBuffer, DataView, Date, RegExp, Map, Set, WeakMap, WeakSet, JsError (Error), and the Intl classes get @instanceOf.
  • Symbols: Symbol.t gets @typeOf("symbol").
  • Typed arrays: each typed array alias gets its own @instanceOf.
  • File and Blob: File.t becomes @instanceOf("File") type t = private Blob.t, so a Blob case excludes files, and (file :> Blob.t) upcasts. option<Blob.t> and option<File.t> are now unboxed.

JsError.t, Symbol.t and the Intl classes can now be untagged variant cases. Jsx.element/Jsx.component, Iterator/AsyncIterator, TypedArray.t<'a>, Intl.Segments, Intl.NumberFormat.Grouping and Lazy keep @notUndefined.

Design

Syntax considered: a grouped @runtime(instanceof("Date")), a string mini-language (@runtime("instanceof Date")), and per-constructor predicates (| @check("$0 instanceof X") Gradient(g), #7147). @instanceOf/@typeOf read like the checks they produce and take a single string, like @as and @tag.

A stored shape rather than reading attributes: reading the annotations along the expansion chain at every use evaluated an interface in its implementation's environment, so a superclass hidden by an interface was lost in its clients. The stored shape also removes the deduplication of annotations repeated through module aliases.

The risk assessment and the re-review, with the programs that found the holes fixed here, are in this comment and the follow-up below it.

Not covered: telling records apart by their properties (#7988), classes imported from modules, and the typed arrays sharing one type (#8955). Moving File and Blob to WebAPI is the next step.

Tests

  • tests/tests/src/untagged_runtime_annotations_test.res: real JS classes, subclasses declared as private abbreviations in both declaration orders, a superclass case matched alone, module aliases, upcasts, typeOf symbol and string, a dotted class path, a phantom type hierarchy, a callable class beside a function case, a class over an object type, and option unboxing, also of an @unboxed wrapper.
  • untagged_runtime_annotations_shadow_test.res, untagged_file_blob_test.res, untagged_builtin_alias_test.res, untagged_hidden_payload_test.res (an interface hiding a payload's class).
  • super_errors: one fixture per rejection above, including instance_of_functor_parameter, type_of_locally_abstract_type, instance_of_public_subclass_through_alias, instance_of_duplicate, untagged_unannotated_stdlib_name.
  • super_errors_multi: Cross_runtime_annotation_hidden_superclass, Cross_runtime_annotation_hidden_base, Cross_runtime_annotation_claimed_by_interface, Cross_runtime_annotation_functor_argument.
  • Analysis: completion of @instanceOf, @typeOf and @notUndefined.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CMkrYaktctdKc4M41WF36t

@cknitt
cknitt added this pull request to stack #8951 October 21, 2026 18:14
@codecov

codecov Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.06557% with 34 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.29%. Comparing base (39bf4a1) to head (3150e2f).

Files with missing lines Patch % Lines
compiler/ml/ast_untagged_variants.ml 80.34% 23 Missing ⚠️
compiler/ml/variant_layout.ml 90.24% 8 Missing ⚠️
compiler/ml/includecore.ml 90.00% 2 Missing ⚠️
compiler/ml/lambda.ml 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #8950      +/-   ##
==========================================
- Coverage   81.29%   81.29%   -0.01%     
==========================================
  Files         456      456              
  Lines       63836    63990     +154     
==========================================
+ Hits        51893    52018     +125     
- Misses      11943    11972      +29     
Files with missing lines Coverage Δ
compiler/core/js_exp_make.ml 68.46% <100.00%> (+0.18%) ⬆️
compiler/core/lam_compile.ml 90.25% <100.00%> (+0.05%) ⬆️
compiler/ml/ctype.ml 67.21% <ø> (ø)
compiler/ml/datarepr.ml 91.91% <ø> (ø)
compiler/ml/predef.ml 100.00% <ø> (ø)
compiler/ml/printtyp.ml 70.60% <ø> (ø)
compiler/ml/subst.ml 85.88% <ø> (ø)
compiler/ml/typecore.ml 86.85% <ø> (ø)
compiler/ml/typedecl.ml 82.82% <100.00%> (+0.05%) ⬆️
compiler/ml/typemod.ml 75.27% <ø> (ø)
... and 9 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 force-pushed the instanceof-typeof-annotations branch from b8c5606 to dbe05fb Compare October 21, 2026 18:22
@cknitt

cknitt commented Oct 21, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 21, 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 🔄 Running since 2026-10-23T11:48:18.173911Z 330fc49 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. Chef's kiss.

Reviewed commit: dbe05fbc5d

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

@pkg-pr-new

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

Copy link
Copy Markdown

Open in StackBlitz

rescript

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

@rescript/belt

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

@rescript/darwin-arm64

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

@rescript/linux-arm64

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

@rescript/linux-x64

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

@rescript/runtime

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

@rescript/win32-x64

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

commit: 3150e2f

@github-actions

Copy link
Copy Markdown

@cknitt
cknitt marked this pull request as ready for review October 21, 2026 19:23
@cknitt
cknitt requested a review from cristianoc October 21, 2026 19:23
@cristianoc

Copy link
Copy Markdown
Collaborator

This should be reviewed when we have more time.
Things like non-overlapping cases etc.
One first step is to review every issue related to untagged variants and make a collection of risks and specs.
Then apply that to this general mechanism.
And possibly add more documentation.

That would be the first PR to begin a review from.

@cristianoc

Copy link
Copy Markdown
Collaborator

@cknitt, before reviewing this stack in detail: #8952 states the current rules of untagged variants, with tests pinning them, and lists open questions for a runtime-class mechanism. Rebasing #8950 → #8948 → #8949 on top of it would let each PR show its change as a diff to those rules.

The main risks I see for the mechanism, each with an example:

  1. Annotations are unchecked claims. @instanceOf("File") type file = private blob asserts a class and a subclass relation that the compiler cannot verify. A wrong claim, such as @typeOf("number") type t = string, sends values to the wrong case. Today such a type is the only payload case, so a wrong claim cannot misroute anything.
  2. Subclasses are known only through private abbreviations. Bindings that declare Element and HTMLElement as independent abstract types bring back Untagged variant with Blob and File cases never matches File when Blob is declared first #8938, and a wrong chain allows an unsound :> upcast.
  3. Overlaps between class, typeof and primitive cases. Classes are not checked against typeof kinds or against each other: @instanceOf("Function") takes every value of a function case, @instanceOf("Object") takes records, and two unrelated classes are decided by declaration order.
  4. Values outside every case. With two payload cases, the second has no check, so a value from a wrong claim, another realm (iframe, worker) or an unannotated subclass lands in a case of the wrong type rather than failing. On master, a Uint8Array passed as I8(u) already matches U8. With three or more, the switch (typeof x) has no default, so such a value matches no arm.
  5. Interfaces and functors. Nothing compares @instanceOf, @typeOf or @notUndefined between an interface and its implementation, or across functor arguments. The implied @notUndefined also decides whether option is unboxed, so a wrong annotation can make Some(None) collapse to None.
  6. Mid-stack mixing. The hard-coded type-name list stays as a fallback until the end of the stack, so annotated and unannotated types are classified by two mechanisms at once.

#8952 also records five bugs reproduced on master (duplicate @as literals compared by spelling, Some(U) with an @as(undefined) case compiling to undefined, #8938, typed arrays, and @as tags not compared across a signature), which the mechanism should not make worse.

Could you share evidence of a detailed assessment of all the risks? For example, sub-agents that each take one risk or one related issue, try to break the mechanism with concrete programs, and report what they found, with the programs and their outputs. #8952's open questions, with their related issues, are a starting list, and the answers could go into it.

🤖 Generated with Claude Code

@cristianoc

Copy link
Copy Markdown
Collaborator

@cknitt, the bugs in #8952 are filed: #8953 (number literals compared by spelling), #8954 (Some of a constant @as(undefined) case), #8955 (typed arrays), #8956 (@as tags across a signature), with #8938 already open. #8955 and #8938 sit in the recognition of instance types that this stack changes, so the assessment should show how #8950 affects them.

@cknitt
cknitt force-pushed the instanceof-typeof-annotations branch from dbe05fb to 16ee5bb Compare October 22, 2026 17:01
@cknitt

cknitt commented Oct 22, 2026

Copy link
Copy Markdown
Member Author

@cristianoc, thanks, this was the right ask. The stack is rebased on #8952: each PR now updates UntaggedVariants.md for the rules it changes, and the open questions became a "Decisions" section with what is still open. The assessment found real holes; the fixes are new commits in #8950, described at the end. GitHub doesn't let me change #8950's base while it's in a stack, so its diff still includes #8952's commit. If you agree, I can link #8952 in as the bottom of the stack.

How

Nine sub-agents, one per open question of #8952, plus one for the mid-stack states, the accuracy of the stdlib annotations and the known bugs. Each one wrote programs, compiled them with two compilers, and ran them in Node 22:

The subclass agent also generated programs: every ordered subset of the cases of a four-class tree (Animal > Dog > Puppy, Animal > Cat), with every subset of arms plus a default, with and without a literal and a string case, and declared in order or as a reversed type rec group. All 18,800 generated checks route correctly, before and after the fixes. The programs are local, so the minimal ones are inline below.

1. Unchecked claims

Stack bug, fixed. An annotation on an abbreviation overrode what the compiler knows about the abbreviated type. The worst case is a type that can hold undefined, because the annotation implies @notUndefined:

@typeOf("string") type s = option<string>
let x: option<option<string>> = Some(id(None))
let y: option<s> = x
switch y { | Some(Some(str)) => "Some(Some(" ++ str ++ "))" | Some(None) => "Some(None)" | None => "None" }
  • base: Some(None)
  • stack: Some(Some([object Object])), the nested-none marker typed as a string.

The same happened with type v<'a> = 'a, unit, Nullable.t<_>, and a variant with an @as(undefined) case. Other wrong claims were accepted too:

  • @typeOf("number") type n = string and @instanceOf("X") type xs = string;
  • @typeOf("object") on int or on a function type;
  • @instanceOf("Foo") type myd = Date.t, a public alias that turns every Date into a Foo.

Now: an annotation on an abbreviation must agree with the abbreviated type. It may refine an abstract, non-builtin type without an annotation, or repeat that type's annotation. A subclass of the class annotated there needs a private abbreviation, or a public one that is a strict instance of the superclass type (the phantom hierarchy Dom.res and WebAPI use, type element<'a> = node<_element<'a>>). Everything else is an error:

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

Still trusted, by design: a claim on an abstract type, like the type of an external, and a chain that is wrong about the runtime. With @instanceOf("Date") type d = private RegExp.t, a Date typed d:

  • runs no arm of R2(RegExp.t) | S2(string) | M2(Map.t<int, int>) once upcast (the switch returns undefined);
  • makes RegExp.test((d :> RegExp.t), "x") throw, as any wrong external would.

2. Subclasses only through private abbreviations

Works when the chain is visible.

Stack bugs, fixed:

  • An interface that hides the manifest. With @instanceOf("Dog") type dog abstract in A.resi, A.inside(ID(dog)) gave dog while a client's g(ID(dog)) gave animal: one variant type, two matchings. Inclusion now compares the classification, superclasses included (section 5).
  • A functor parameter with an abstract subclass: the same fix.
  • Inverted or cyclic chains. @instanceOf("Blob") type b2 = private File.t compiled to v instanceof File && !(v instanceof Blob), which is never true, and the 3+ arm form ran no arm. Now: "Blob appears twice in this chain of abbreviations, so it would be its own superclass."
  • A subclass on a public abbreviation. @instanceOf("Dog") type dog = animal let any animal be wrapped in D without a coercion. Now rejected, unless it is a strict instance as above.

Pre-existing in kind, documented: classes declared as independent abstract types are tested in declaration order, which is #8938 for every binding without a chain. Annotating the _like abbreviations of a phantom hierarchy works: it was checked with Node > Element > HTMLElement, including a polymorphic AnyEl(element_like<'a>) case.

3. Overlaps between class, typeof and primitive cases

Stack bugs, fixed: class paths that name no class to recognize values by are rejected.

  • Object and Function swallowed every object or function case: a Date gave O, and functions gave Fn, never F. They now point to @typeOf("object") / @typeOf("function").
  • Array used instanceof, not Array.isArray; it now points to array<_>.
  • String, Number, Boolean, BigInt and Symbol are wrappers that primitives are not instances of: 1n fell through to the string case. They now point to the @typeOf.
  • undefined, NaN, Infinity, globalThis, Math, JSON, Reflect, Atomics, Intl and Proxy threw a TypeError for every value.
  • globalThis.Date duplicated Date without being detected, so G(date) matched D. The compiler adds globalThis itself where a binding shadows the class, so the prefix is rejected.

Stack bug, fixed: in the two-arm form, the function check now excludes class cases as the object check does, so the two forms agree on a class whose instances are callable:

if (typeof v === "function" && !(v instanceof Fn)) {

Documented:

  • unrelated classes are tested in declaration order;
  • Symbol.hasInstance is trusted;
  • a declared kind counts as the builtin kind (rule 6);
  • @typeOf("object") admits null unless an @as(null) case is tested first, as for records.

Pre-existing, next to #8953: @as(1) One | @as(1n) OneN | S(string) is rejected with "Duplicate literal 1.", although 1 !== 1n. It is the converse of #8953 (literals compared by spelling), so I'd add it there rather than open an issue.

4. Values outside every case

Unchanged: for stdlib-only programs, the dispatch code is byte-identical to master. The unchecked last branch is reachable from more types now (JsError, Intl, user classes), but in the same way. Rule 10 now states:

  • the unchecked branch stays the contract;
  • recognition is local to a realm;
  • a class missing at run time makes its check throw for every value. Instance checks precede the typeof switch, so "s" reaches v instanceof Nope too. Nothing happens at module load.

Design question: emit the primitive typeof cases before the instance checks. Then a missing class would only affect objects, and primitives would take a cheaper path. It changes the output of existing code, so I left it for a separate PR.

5. Interfaces and functors

Stack bugs, fixed. Inclusion compared none of the annotations, so an interface could:

  • claim a class its implementation lacks;
  • state a different class or kind;
  • or hide a superclass.

This held for .resi files, functor arguments, first-class modules and with type. An example of a false claim, with A.resi saying @typeOf("string") type t and A.res saying type t = option<string>:

  • base: Some(A.none()) stays Some;
  • stack: Some(A.none()) was None.

Now: a type for which the interface states a class or typeof, through its own annotation or its manifest, must state the same one in the implementation, superclasses included. An interface that states none hides it. The payload kind of every untagged case is compared too.

Their runtime representations differ:
the first declaration states instances of Dog, a subclass of Animal,
the second declaration states instances of Dog.

Pre-existing, unchanged: @notUndefined alone is still not compared. An abstract @notUndefined type t over type t = option<int> loses Some(None) on master too.

6. Mid-stack mixing

Stdlib accuracy: every annotated stdlib constructor and external produces an instance of the declared class, or a value of the declared typeof, checked in Node:

  • the Date, RegExp, Map, Set, WeakMap, WeakSet, ArrayBuffer, DataView and JsError constructors (and all six error subclasses);
  • the eight Intl.*.make and Locale.maximize/minimize;
  • Symbol.make/getFor and the 13 well-known symbols;
  • the fromArray of all eleven typed arrays, and slice/subarray/map/filter.

No stdlib function casts a foreign value into an annotated type. File.t = private Blob.t breaks nothing in the repo.

Stack regression, fixed: @unboxed type boxDate = BoxDate(Date.t) was no longer unboxed in option once Date carried @instanceOf instead of @notUndefined. The check now also reads annotations on the scraped type.

The related issues

Issue Effect of the stack
#8938 (Blob/File order) fixed by #8948, in both orders, also in a superclass-only switch
#8955 (typed arrays) unchanged: Int8Array.t = Uint8Array.t still typechecks, and I8(u8) matches U8 on both compilers. Making the aliases private would need :> for every TypedArray call. The spec now names this, so it is the moment to decide
#8953, #8954, #8956 unchanged; the spec's programs give the same output on both compilers

The fixes

New commits at the top of #8950; #8948 and #8949 each gain a spec commit:

  • Check runtime annotations against the types they describe: the abbreviation rule, rejected class paths, the function check, and unboxed wrappers.
  • Compare runtime annotations and untagged payloads across signatures: Includecore.
  • Document runtime annotations in the untagged variants spec: rules 4, 5, 6, 8, 10, 12, 13 and 14, and the decisions.

Each rejection has a super_errors fixture, the interface cases are in super_errors_multi, and the runtime cases (phantom hierarchy, callable class, boxed wrapper) are in untagged_runtime_annotations_test.res.

Still open, listed at the end of the spec:

🤖 Generated with Claude Code

@cknitt
cknitt removed this pull request from stack #8951 October 22, 2026 18:46
@cknitt
cknitt changed the base branch from master to cristianoc/untagged-variants-spec October 22, 2026 18:47
@cknitt cknitt changed the title Declare the runtime class or typeof of abstract types Declare the runtime class or typeof of types, and annotate the stdlib Oct 22, 2026
Base automatically changed from cristianoc/untagged-variants-spec to master October 22, 2026 18:51
@cknitt
cknitt force-pushed the instanceof-typeof-annotations branch from 25a5ddc to 9a2bcf9 Compare October 22, 2026 19:14
@cknitt
cknitt added this pull request to stack #8968 October 22, 2026 19:15
@cknitt

cknitt commented Oct 22, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. You're on a roll.

Reviewed commit: 9a2bcf9a7c

ℹ️ 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 force-pushed the instanceof-typeof-annotations branch 2 times, most recently from 7d2fd82 to 330fc49 Compare October 23, 2026 11:47
@cknitt

cknitt commented Oct 23, 2026

Copy link
Copy Markdown
Member Author

@cristianoc, a follow-up to the assessment above. After the first round of fixes I had a fresh agent re-review the whole feature for correctness. It had only the diff and the spec, not my conclusions. Here is what it found and what changed. The stack is now #8950 (with the former #8948 and #8949 folded in) → #8966 → #8967, rebased on master.

Fixed in #8950

  • Refactor: the resolved shape is stored on the declaration. The annotations used to be re-read from attributes along the expansion chain at every use, in the reader's environment. Interface inclusion therefore evaluated .resi's @instanceOf("Dog") type dog = private animal against the implementation's annotated animal, while clients saw an unannotated animal. A Dog then lost its superclass in clients and landed in an Animal case.

    • Now type_shape (Unstated, Never_undefined or Recognized_as a class with its superclasses, or a kind) is resolved when a type is declared, in its own environment.
    • Readers stop at the first type on a chain that states a shape.
    • The deduplication for module aliases is gone.
    • New fixture: Cross_runtime_annotation_hidden_base.
  • Functor parameters and locally abstract types. @instanceOf("Date") type d = X.t in a functor was accepted, because X.t looks abstract.

    • Applied to {type t = option<int>}, Some(None) became None.
    • With X.t = int and @typeOf("string"), a well-typed program matched the wrong case.

    Such refinements are now rejected, and so are those of types of recursive modules being defined.

  • False rejection: an interface hiding a payload's class. A .resi with type t and @unboxed type v = | @as("n") N | P(t), over type t = Date.t, compiles on master and was rejected. An unknown payload in the interface is the only payload case and is never checked, so it now agrees with any implementation payload.

  • The phantom exception was syntactic. type phantom<'a> = animal with @instanceOf("Dog") type dog = phantom<int> passed as a "strict instance". The check now reads the arguments after expansion.

  • Minor:

    • a class over a @typeOf("object") type is accepted;
    • two @instanceOf attributes get their own message;
    • the completion doc is corrected.

Fixed in #8966 (@notUndefined against the implementation)

  • False rejections: an interface's @notUndefined type t over type t = Dom.element, or over a JS object type. An implementation whose representation the compiler doesn't see now passes: an abstract type, also through an abbreviation, or an object type. A functor parameter's type doesn't count as abstract.

Fixed in #8967 (typeof first)

  • Two places still ran class checks on primitives:

    • the two-arm if with the class arm first;
    • the literal test in its negated form.

    So a Symbol.hasInstance could claim a string, and a missing class threw for a literal.

    • The two-arm form now checks the non-class arm.
    • A variant with class cases tests its literals by comparison.
  • Untagged variant switch with a default arm matches instances and literals in the wrong case #8995 (pre-existing on master): a switch with a _ arm matched a Date in its object arm, and an @as("lit") literal in its string arm.

    • The typeof switch is now preceded by a guard sending instances of cases without an arm to the default, which generalizes the existing array guard.
    • The literal test is only skipped when no literal can pass a payload arm's check.
    • The rule 10 example of an unchecked branch changes accordingly: PPromise | PFloat now checks the float arm, so a non-Promise thenable lands in PPromise.

Re-checked

  • The subclass generators: 18,800 values, routed correctly.
  • The interface, functor and option programs from the first round.
  • The full test suite at each PR tip.
  • Codex has reviewed every PR before this round; I've asked it again.

Filed separately:

🤖 Generated with Claude Code

@cknitt

cknitt commented Oct 23, 2026

Copy link
Copy Markdown
Member Author

@codex review

@github-actions

github-actions Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Changelog

  • 🚀 Add @instanceOf("Class") and @typeOf("…") for abstract types and type abbreviations, so that values of bound JavaScript classes and opaque primitive types can be untagged variant cases. A private abbreviation of another annotated type declares a subclass.
  • 🐛 An untagged variant with both Blob and File cases matches files as File, whatever the order of its cases. File.t is now a private abbreviation of Blob.t, and JsError.t, Symbol.t and the Intl classes can be untagged variant cases.
  • 🏠 Untagged variants recognize stdlib classes only through their @instanceOf annotations; the compiler's list of stdlib type names is gone.

Semantic Diff

11 commits · Full page

Declare the runtime class or typeof of abstract types

ad4d423 · cknitt · Oct 23, 2026, 17:25 UTC

Why

Untagged variants recognize payloads of abstract types only from a hard-coded list of stdlib type names, which knows no subclasses and is closed to bindings such as WebAPI's (#7147).

@instanceOf("Class") and @typeOf("…") on an abstract type or a type abbreviation state how its values are recognized at run time. The classifier reads them along the type's expansion chain: the outermost one decides, and the classes annotated further down a chain of private abbreviations are its superclasses. A class check excludes the subclasses that are cases too. Both imply that a value is never undefined. The name list stays as fallback for types without annotations. Editor completion offers both, and @notUndefined, which was listed with the file-level @@ decorators, now completes after a single @.

Representation added (untagged variant payload)

@instanceOf("Animal") type animal
@instanceOf("Dog") type dog = private animal
@typeOf("symbol") type sym

@unboxed
type t = Animal(animal) | Dog(dog) | Sym(sym)

representation · After

Animal(a) ↦ a, recognized by a instanceof Animal && !(a instanceof Dog)
Dog(d)    ↦ d, recognized by d instanceof Dog
Sym(s)    ↦ s, recognized by typeof s === "symbol"

Diagnostic added (invalid annotation)

@typeOf("undefined") type t

diagnostic · After

@typeOf expects one of "string", "number", "bigint", "boolean", "symbol", "function" or "object", not "undefined".

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>

# Conflicts: # compiler/ext/config.ml

Review

  • Why @instanceOf/@typeOf rather than one @runtime(...) attribute? They read like the JavaScript checks they produce, take a single string like @as and @tag, and sit next to the existing @notUndefined.
  • Why also on public abbreviations? The typed array types are public aliases of TypedArray.t<'a> that users pass to TypedArray functions, so they can only be told apart by the name as written, as the name list does.
Check runtime annotations against the types they describe

62fc5e3 · cknitt + Claude · Oct 22, 2026, 16:50 UTC

Why

An annotation on an abbreviation was trusted whatever the abbreviated type was. @typeOf("string") type s = option<string> made Some(None) of s the same value as None, and two spellings of one type were recognized differently. Paths such as Object, BigInt or globalThis.Date were accepted and misrecognized every value or duplicated another class.

An annotation on an abbreviation must now refine an abstract type that is not builtin, repeat the abbreviated type's annotation, or declare a subclass of the class annotated there. A subclass needs a private abbreviation, or a public one that is a strict instance of the superclass type (a phantom type hierarchy), and must not repeat a class of its chain. Paths that name no class to recognize values by are rejected. Payloads of @unboxed wrappers of annotated types are never undefined either, as with @notUndefined, and the function check excludes class cases as the object check does, so the two-arm form agrees with the switch form.

Diagnostic added (annotation on a type that can be undefined)

@typeOf("string") type s = option<string>

diagnostic · After

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

Diagnostic added (subclass on a public abbreviation)

@instanceOf("Animal") type animal
@instanceOf("Dog") type dog = animal

diagnostic · After

A subclass must be a private abbreviation: write `= private ...` so that instances of Animal cannot be used as instances of Dog without a coercion.

Diagnostic added (class path)

@instanceOf("BigInt") type t

diagnostic · After

Values of type bigint are primitives, not instances of BigInt. Use @typeOf("bigint") instead.

JS modified (option of an unboxed wrapper)

@unboxed type boxedAnimal = BoxedAnimal(animal)
let x: option<boxedAnimal> = Some(BoxedAnimal(a))

js

-let x = Primitive_option.some(a);
+let x = a;

JS modified (function case beside a class case)

@unboxed type handler = Function(int => int) | Callable(callable)

js

-if (typeof handler === "function") {
+if (typeof handler === "function" && !(handler instanceof UntaggedCallable)) {
   return "function";
 }

Review

  • Why allow public subclasses that are strict instances? Dom.res and WebAPI model class hierarchies with phantom types, such as type element<'a> = node<_element<'a>>, whose subclass types unify with the superclass type only at the phantom argument. type dog = animal is the same type as animal and is rejected.
  • Why reject globalThis.C instead of normalizing it? The compiler already reads a shadowed class through globalThis, so the prefix adds nothing, and one spelling per class keeps the duplicate and subclass checks, which compare paths, exact.
Compare runtime annotations and untagged payloads across signatures

152c9e1 · cknitt + Claude · Oct 22, 2026, 16:51 UTC

Why

Interface inclusion ignored @instanceOf/@typeOf. An interface could claim a class its implementation does not have, or hide a private superclass manifest; the implementation and its clients then compiled different checks for the same values, and #8938 came back in the clients. Functor arguments, with type constraints and module type ascriptions had the same hole.

When an interface states a class or typeof for a type, through its own annotation or the type it abbreviates, the implementation must state the same one, superclasses included. An interface that states none hides it. The payload kind of every untagged case is compared too, so a variant declared in both is recognized the same way on both sides.

Diagnostic added (hidden superclass)

// Classes.res
@instanceOf("Animal") type animal
@instanceOf("Dog") type dog = private animal
// Classes.resi
@instanceOf("Animal") type animal
@instanceOf("Dog") type dog

diagnostic · After

Type declarations do not match:
  type dog = animal
is not included in
  type dog
Their runtime representations differ:
the first declaration states instances of Dog, a subclass of Animal,
the second declaration states instances of Dog.

Diagnostic added (claim the implementation does not make)

// Claim.res
type t = option<string>
// Claim.resi
@typeOf("string") type t

diagnostic · After

Their runtime representations differ:
the first declaration states no runtime class or typeof,
the second declaration states values whose typeof is "string".

Review

Document runtime annotations in the untagged variants spec

c1af618 · cknitt + Claude · Oct 22, 2026, 16:51 UTC

Why

The spec states the rules the compiler implements, and its open questions asked what a mechanism for declaring classes and kinds on types has to decide.

Rule 5 now describes @instanceOf and @typeOf: where they can appear, how they are read along a chain of abbreviations, which abbreviations and class paths are rejected, and the name list that still covers types without annotations. Rules 4, 6, 8, 10, 12, 13 and 14 gain what the annotations change: declared kinds, one case per class and per symbol, subclass and function exclusions, missing classes, implied never-undefined, and inclusion checks. The open questions become the decisions taken, with what is still open.

Annotate the stdlib's runtime classes

858a5c4 · cknitt + Claude · Oct 21, 2026, 18:01 UTC

Why

Untagged variants recognized stdlib classes only by type name, with no relation between them, so a Blob case declared before a File case also caught files (#8938).

Types whose values are instances of a global class now say so with @instanceOf instead of @notUndefined, Symbol.t with @typeOf("symbol"), and File.t is a private abbreviation of Blob.t. JsError.t, Symbol.t and the Intl classes can be untagged variant cases too. Fixes #8938.

JS modified (Blob and File cases)

@unboxed
type upload = Blob(Blob.t) | File(File.t)

let kind = upload =>
  switch upload {
  | Blob(_) => "blob"
  | File(_) => "file"
  }

js

-if (upload instanceof Blob) {
+if (upload instanceof Blob && !(upload instanceof File)) {
   return "blob";
 } else {
   return "file";
 }
Document the stdlib's runtime annotations in the untagged variants spec

9c6de8b · cknitt + Claude · Oct 22, 2026, 16:53 UTC

Why

With the stdlib annotated, Blob.t and File.t are never undefined and #8938 is fixed, which the spec and its test still stated the other way round.

Rule 5 lists the stdlib's annotations, rule 12 unboxes option of a variant with a Blob.t payload, and the known bug of overlapping classes is gone. The typed-array bug stays, filed as #8955.

JS modified (option of a variant with a Blob payload)

@unboxed type withBlob = BBlob(Blob.t) | BString(string)
let someWithBlob = (v: withBlob): option<withBlob> => Some(v)

js

-return Primitive_option.some(v);
+return v;
Recognize untagged payloads only by annotations and builtin types

31cb314 · cknitt + Claude · Oct 21, 2026, 18:04 UTC

Why

With the stdlib annotated, the classifier's list of stdlib type names only matched types that happen to be printed like a stdlib type, and its name-based dict check took any type named dict for an object.

The list, matched before and after expansion, is gone, and so is the dict check: a payload is recognized by its @instanceOf or @typeOf, or by the builtin type it expands to. Generated code is unchanged for the stdlib's types.

Diagnostic added (a local type named dict)

type dict<'a>
@unboxed type t = D(dict<int>) | S(string)

js · Before

if (typeof v === "object") { return "D"; } else { return "S"; }

diagnostic · After

This untagged variant definition is invalid: Case D has a payload that is not of one of the recognized shapes (object, array, etc). Then it must be the only case with payloads.
Test that payloads are not recognized by type name

dae5d20 · cknitt + Claude · Oct 21, 2026, 18:36 UTC

Why

The classifier used to recognize payloads by the printed path of their type; nothing pinned that it no longer does.

An unannotated local Stdlib_Date.t, which the removed list would have taken for a date, is an unknown payload, and aliases of builtin types are recognized through their expansion.

Drop the name list from the untagged variants spec

735bb93 · cknitt + Claude · Oct 22, 2026, 16:54 UTC

Why

Payloads are now recognized only by annotations and builtin types, so the spec's list of stdlib type names and the fallback to it are gone.

Rule 4 lists promise<_> as the one builtin class and says that a type is never recognized by its name; rule 5 keeps the stdlib's annotations.

Store the resolved runtime shape on type declarations

c0141e6 · cknitt · Oct 23, 2026, 17:25 UTC

Why

Every reader re-derived what @instanceOf, @typeOf and @notUndefined state by walking a type's expansion chain in the reader's environment. Interface inclusion therefore evaluated an interface's @instanceOf("Dog") type dog = private animal against the implementation's annotated animal, while clients saw an unannotated animal: a superclass hidden that way was lost in clients.

A declaration now carries type_shape: Unstated, Never_undefined, or Recognized_as a class with its superclasses or a typeof kind, resolved when the type is declared, in the environment it is declared in. The classifier, option unboxing and inclusion read it, walking only to the first declaration that states a shape; no attribute is parsed after declaration, and the deduplication of annotations repeated through module aliases is gone.

Diagnostic added (a superclass hidden by its interface)

// Classes.res
@instanceOf("Animal") type animal
@instanceOf("Dog") type dog = private animal
// Classes.resi
type animal
@instanceOf("Dog") type dog = private animal

diagnostic · After

Their runtime representations differ:
the first declaration states instances of Dog, a subclass of Animal,
the second declaration states instances of Dog.

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>

# Conflicts: # compiler/ext/config.ml

Review

  • Why a field rather than a normalized attribute? The facts are typed data, not text, and a field cannot be printed or forwarded by tools that handle attributes.
Close the gaps the re-review of runtime annotations found

3150e2f · cknitt + Claude · Oct 23, 2026, 11:31 UTC

Why

An annotation could refine a functor parameter's or a locally abstract type, which stands for any type: @instanceOf("Date") type d = X.t in a functor applied to option<int> made Some(None) collapse to None. An abbreviation through an alias passed as a phantom subclass, an interface hiding a payload's class was rejected although master accepts it, and two @instanceOf attributes were reported as a conflict between the two kinds.

Annotations on the type of a functor parameter, of a recursive module being defined, or on a locally abstract type are rejected. The phantom-hierarchy exception reads the abbreviation's arguments after expansion. A class can be declared over a @typeOf("object") type. An interface case with an unknown payload agrees with any payload of the implementation, since it is the only payload case and is never checked.

Diagnostic added (a type that stands for another one)

module Make = (X: {type t}) => {
  @instanceOf("Date") type date = X.t
}

diagnostic · After

@instanceOf cannot describe the type of a functor parameter, of a recursive module being defined, or a locally abstract type: such a type stands for another one, whose values can be anything.

Diagnostic added (public subclass through an alias)

@instanceOf("Animal") type animal
type phantom<'a> = animal
@instanceOf("Dog") type dog = phantom<int>

diagnostic · After

A subclass must be a private abbreviation: write `= private ...` so that instances of Animal cannot be used as instances of Dog without a coercion.

Diagnostic removed (an interface hiding a payload's class)

// A.resi
type t
@unboxed type v = | @as("n") N | P(t)
// A.res
type t = Date.t
@unboxed type v = | @as("n") N | P(t)

diagnostic · Before

The internal representations for case P are not equal

cknitt and others added 11 commits October 23, 2026 17:49
Why:
    Untagged variants recognize payloads of abstract types only from a
    hard-coded list of stdlib type names, which knows no subclasses and is
    closed to bindings such as WebAPI's (#7147).

`@instanceOf("Class")` and `@typeOf("…")` on an abstract type or a type
abbreviation state how its values are recognized at run time. The
classifier reads them along the type's expansion chain: the outermost one
decides, and the classes annotated further down a chain of private
abbreviations are its superclasses. A class check excludes the subclasses
that are cases too. Both imply that a value is never undefined. The name
list stays as fallback for types without annotations. Editor completion
offers both, and `@notUndefined`, which was listed with the file-level
`@@` decorators, now completes after a single `@`.

Representation added (untagged variant payload):
Example:
    @instanceof("Animal") type animal
    @instanceof("Dog") type dog = private animal
    @typeof("symbol") type sym

    @unboxed
    type t = Animal(animal) | Dog(dog) | Sym(sym)
After (representation):
    Animal(a) ↦ a, recognized by a instanceof Animal && !(a instanceof Dog)
    Dog(d) ↦ d, recognized by d instanceof Dog
    Sym(s) ↦ s, recognized by typeof s === "symbol"

Diagnostic added (invalid annotation):
Example:
    @typeof("undefined") type t
After (diagnostic):
    @typeof expects one of "string", "number", "bigint", "boolean", "symbol", "function" or "object", not "undefined".

Review: Why `@instanceOf`/`@typeOf` rather than one `@runtime(...)`
attribute? They read like the JavaScript checks they produce, take a single
string like `@as` and `@tag`, and sit next to the existing `@notUndefined`.
Review: Why also on public abbreviations? The typed array types are public
aliases of `TypedArray.t<'a>` that users pass to `TypedArray` functions, so
they can only be told apart by the name as written, as the name list does.

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>

# Conflicts:
#	compiler/ext/config.ml
Why:
    An annotation on an abbreviation was trusted whatever the abbreviated
    type was. `@typeOf("string") type s = option<string>` made `Some(None)`
    of `s` the same value as `None`, and two spellings of one type were
    recognized differently. Paths such as `Object`, `BigInt` or
    `globalThis.Date` were accepted and misrecognized every value or
    duplicated another class.

An annotation on an abbreviation must now refine an abstract type that is
not builtin, repeat the abbreviated type's annotation, or declare a subclass
of the class annotated there. A subclass needs a private abbreviation, or a
public one that is a strict instance of the superclass type (a phantom type
hierarchy), and must not repeat a class of its chain. Paths that name no
class to recognize values by are rejected. Payloads of `@unboxed` wrappers of
annotated types are never undefined either, as with `@notUndefined`, and the
function check excludes class cases as the object check does, so the
two-arm form agrees with the `switch` form.

Diagnostic added (annotation on a type that can be undefined):
Example:
    @typeof("string") type s = option<string>
After (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.

Diagnostic added (subclass on a public abbreviation):
Example:
    @instanceof("Animal") type animal
    @instanceof("Dog") type dog = animal
After (diagnostic):
    A subclass must be a private abbreviation: write `= private ...` so that instances of Animal cannot be used as instances of Dog without a coercion.

Diagnostic added (class path):
Example:
    @instanceof("BigInt") type t
After (diagnostic):
    Values of type bigint are primitives, not instances of BigInt. Use @typeof("bigint") instead.

JS modified (option of an unboxed wrapper):
Example:
    @unboxed type boxedAnimal = BoxedAnimal(animal)
    let x: option<boxedAnimal> = Some(BoxedAnimal(a))
Before (js): let x = Primitive_option.some(a);
After (js): let x = a;

JS modified (function case beside a class case):
Example:
    @unboxed type handler = Function(int => int) | Callable(callable)
Before (js):
    if (typeof handler === "function") {
      return "function";
    }
After (js):
    if (typeof handler === "function" && !(handler instanceof UntaggedCallable)) {
      return "function";
    }

Review: Why allow public subclasses that are strict instances? `Dom.res`
and WebAPI model class hierarchies with phantom types, such as
`type element<'a> = node<_element<'a>>`, whose subclass types unify with the
superclass type only at the phantom argument. `type dog = animal` is the same
type as `animal` and is rejected.
Review: Why reject `globalThis.C` instead of normalizing it? The compiler
already reads a shadowed class through `globalThis`, so the prefix adds
nothing, and one spelling per class keeps the duplicate and subclass checks,
which compare paths, exact.

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>
Why:
    Interface inclusion ignored `@instanceOf`/`@typeOf`. An interface could
    claim a class its implementation does not have, or hide a private
    superclass manifest; the implementation and its clients then compiled
    different checks for the same values, and #8938 came back in the
    clients. Functor arguments, `with type` constraints and module type
    ascriptions had the same hole.

When an interface states a class or `typeof` for a type, through its own
annotation or the type it abbreviates, the implementation must state the same
one, superclasses included. An interface that states none hides it. The
payload kind of every untagged case is compared too, so a variant declared
in both is recognized the same way on both sides.

Diagnostic added (hidden superclass):
Example:
    // Classes.res
    @instanceof("Animal") type animal
    @instanceof("Dog") type dog = private animal
    // Classes.resi
    @instanceof("Animal") type animal
    @instanceof("Dog") type dog
After (diagnostic):
    Type declarations do not match:
      type dog = animal
    is not included in
      type dog
    Their runtime representations differ:
    the first declaration states instances of Dog, a subclass of Animal,
    the second declaration states instances of Dog.

Diagnostic added (claim the implementation does not make):
Example:
    // Claim.res
    type t = option<string>
    // Claim.resi
    @typeof("string") type t
After (diagnostic):
    Their runtime representations differ:
    the first declaration states no runtime class or typeof,
    the second declaration states values whose typeof is "string".

Review: Why compare payload kinds when the payload types are already
compared? Equal payload types are classified alike only when both sides see
the same annotations; an interface that hides one (stating none, which is
allowed) gives its variant another layout.
Review: Why not compare `@notUndefined` too? It has the same hole on master
(#8964), fixed separately in #8966; the implied never-undefined of the new
annotations is covered here, because the classification includes it.

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>
Why:
    The spec states the rules the compiler implements, and its open
    questions asked what a mechanism for declaring classes and kinds on
    types has to decide.

Rule 5 now describes `@instanceOf` and `@typeOf`: where they can appear,
how they are read along a chain of abbreviations, which abbreviations and
class paths are rejected, and the name list that still covers types without
annotations. Rules 4, 6, 8, 10, 12, 13 and 14 gain what the annotations
change: declared kinds, one case per class and per symbol, subclass and
function exclusions, missing classes, implied never-undefined, and inclusion
checks. The open questions become the decisions taken, with what is still
open.

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>
Why:
    Untagged variants recognized stdlib classes only by type name, with no
    relation between them, so a `Blob` case declared before a `File` case
    also caught files (#8938).

Types whose values are instances of a global class now say so with
`@instanceOf` instead of `@notUndefined`, `Symbol.t` with
`@typeOf("symbol")`, and `File.t` is a private abbreviation of `Blob.t`.
`JsError.t`, `Symbol.t` and the `Intl` classes can be untagged variant
cases too. Fixes #8938.

JS modified (Blob and File cases):
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) {
      return "blob";
    } else {
      return "file";
    }
After (js):
    if (upload instanceof Blob && !(upload instanceof File)) {
      return "blob";
    } else {
      return "file";
    }

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>
Why:
    With the stdlib annotated, `Blob.t` and `File.t` are never undefined
    and #8938 is fixed, which the spec and its test still stated the other
    way round.

Rule 5 lists the stdlib's annotations, rule 12 unboxes `option` of a
variant with a `Blob.t` payload, and the known bug of overlapping classes is
gone. The typed-array bug stays, filed as #8955.

JS modified (option of a variant with a Blob payload):
Example:
    @unboxed type withBlob = BBlob(Blob.t) | BString(string)
    let someWithBlob = (v: withBlob): option<withBlob> => Some(v)
Before (js): return Primitive_option.some(v);
After (js): return v;

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>
Why:
    With the stdlib annotated, the classifier's list of stdlib type names
    only matched types that happen to be printed like a stdlib type, and
    its name-based dict check took any type named `dict` for an object.

The list, matched before and after expansion, is gone, and so is the dict
check: a payload is recognized by its `@instanceOf` or `@typeOf`, or by the
builtin type it expands to. Generated code is unchanged for the stdlib's
types.

Diagnostic added (a local type named dict):
Example:
    type dict<'a>
    @unboxed type t = D(dict<int>) | S(string)
Before (js): if (typeof v === "object") { return "D"; } else { return "S"; }
After (diagnostic):
    This untagged variant definition is invalid: Case D has a payload that is not of one of the recognized shapes (object, array, etc). Then it must be the only case with payloads.

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>
Why:
    The classifier used to recognize payloads by the printed path of their
    type; nothing pinned that it no longer does.

An unannotated local `Stdlib_Date.t`, which the removed list would have
taken for a date, is an unknown payload, and aliases of builtin types are
recognized through their expansion.

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>
Why:
    Payloads are now recognized only by annotations and builtin types, so
    the spec's list of stdlib type names and the fallback to it are gone.

Rule 4 lists `promise<_>` as the one builtin class and says that a type is
never recognized by its name; rule 5 keeps the stdlib's annotations.

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>
Why:
    Every reader re-derived what `@instanceOf`, `@typeOf` and `@notUndefined`
    state by walking a type's expansion chain in the reader's environment.
    Interface inclusion therefore evaluated an interface's
    `@instanceOf("Dog") type dog = private animal` against the
    implementation's annotated `animal`, while clients saw an unannotated
    `animal`: a superclass hidden that way was lost in clients.

A declaration now carries `type_shape`: `Unstated`, `Never_undefined`, or
`Recognized_as` a class with its superclasses or a `typeof` kind, resolved
when the type is declared, in the environment it is declared in. The
classifier, `option` unboxing and inclusion read it, walking only to the
first declaration that states a shape; no attribute is parsed after
declaration, and the deduplication of annotations repeated through module
aliases is gone.

Diagnostic added (a superclass hidden by its interface):
Example:
    // Classes.res
    @instanceof("Animal") type animal
    @instanceof("Dog") type dog = private animal
    // Classes.resi
    type animal
    @instanceof("Dog") type dog = private animal
After (diagnostic):
    Their runtime representations differ:
    the first declaration states instances of Dog, a subclass of Animal,
    the second declaration states instances of Dog.

Review: Why a field rather than a normalized attribute? The facts are typed
data, not text, and a field cannot be printed or forwarded by tools that
handle attributes.

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>

# Conflicts:
#	compiler/ext/config.ml
Why:
    An annotation could refine a functor parameter's or a locally abstract
    type, which stands for any type: `@instanceOf("Date") type d = X.t` in a
    functor applied to `option<int>` made `Some(None)` collapse to `None`. An
    abbreviation through an alias passed as a phantom subclass, an interface
    hiding a payload's class was rejected although master accepts it, and two
    `@instanceOf` attributes were reported as a conflict between the two
    kinds.

Annotations on the type of a functor parameter, of a recursive module being
defined, or on a locally abstract type are rejected. The phantom-hierarchy
exception reads the abbreviation's arguments after expansion. A class can
be declared over a `@typeOf("object")` type. An interface case with an
unknown payload agrees with any payload of the implementation, since it is
the only payload case and is never checked.

Diagnostic added (a type that stands for another one):
Example:
    module Make = (X: {type t}) => {
      @instanceof("Date") type date = X.t
    }
After (diagnostic):
    @instanceof cannot describe the type of a functor parameter, of a recursive module being defined, or a locally abstract type: such a type stands for another one, whose values can be anything.

Diagnostic added (public subclass through an alias):
Example:
    @instanceof("Animal") type animal
    type phantom<'a> = animal
    @instanceof("Dog") type dog = phantom<int>
After (diagnostic):
    A subclass must be a private abbreviation: write `= private ...` so that instances of Animal cannot be used as instances of Dog without a coercion.

Diagnostic removed (an interface hiding a payload's class):
Example:
    // A.resi
    type t
    @unboxed type v = | @as("n") N | P(t)
    // A.res
    type t = Date.t
    @unboxed type v = | @as("n") N | P(t)
Before (diagnostic):
    The internal representations for case P are not equal

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 instanceof-typeof-annotations branch from 667b281 to 3150e2f Compare October 23, 2026 17:50

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.

Untagged variant with Blob and File cases never matches File when Blob is declared first

2 participants