Semantic Diff

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

6 commits

b123b7c38f8d78ba50fa3a8044fa63aafe83faab..pr-8950

9b7659213 · Christoph Knittel with Claude Opus 5.5 · Oct 21, 2026

Declare the runtime class or typeof of abstract types

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

addedRepresentation untagged variant payload

@instanceOf("Animal") type animal@instanceOf("Dog") type dog = private animal@typeOf("symbol") type sym @unboxedtype 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"

addedDiagnostic invalid annotation

@typeOf("undefined") type t
diagnostic
After
@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.
  • 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.
0d5496d42 · Christoph Knittel with Claude Opus 5.5 · Oct 22, 2026

Check runtime annotations against the types they describe

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.

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

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

addedDiagnostic class path

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

modifiedJS option of an unboxed wrapper

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

modifiedJS function case beside a class case

@unboxed type handler = Function(int => int) | Callable(callable)
js
Before
After
if (typeof handler === "function") {if (typeof handler === "function" && !(handler instanceof UntaggedCallable)) { return "function"; 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.
11185e9da · Christoph Knittel with Claude Opus 5.5 · Oct 22, 2026

Compare runtime annotations and untagged payloads across signatures

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.

addedDiagnostic 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 = animalis not included in  type dogTheir runtime representations differ:the first declaration states instances of Dog, a subclass of Animal,the second declaration states instances of Dog.

addedDiagnostic claim the implementation does not make

// Claim.restype 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

  • 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.
  • 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.
c8c58b4df · Christoph Knittel with Claude Opus 5.5 · Oct 22, 2026

Document runtime annotations in the untagged variants spec

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.

09ec86bbb · Christoph Knittel with Claude Opus 5.5 · Oct 21, 2026

Annotate the stdlib's runtime classes

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.

modifiedJS Blob and File cases

@unboxedtype upload = Blob(Blob.t) | File(File.t) let kind = upload =>  switch upload {  | Blob(_) => "blob"  | File(_) => "file"  }
js
Before
After
if (upload instanceof Blob) {if (upload instanceof Blob && !(upload instanceof File)) { return "blob"; return "blob";} else {} else { return "file"; return "file";}}
63ef81c1c · Christoph Knittel with Claude Opus 5.5 · Oct 22, 2026

Document the stdlib's runtime annotations in the untagged variants spec

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.

modifiedJS 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
Before
After
return Primitive_option.some(v);return v;