Skip to content

Check @notUndefined of an interface against the implementation - #8966

Merged
cknitt merged 2 commits into
instanceof-typeof-annotationsfrom
not-undefined-inclusion
Oct 24, 2026
Merged

cknitt merged 2 commits into
instanceof-typeof-annotationsfrom
not-undefined-inclusion

Conversation

@cknitt

@cknitt cknitt commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

Fixes #8964. Stacked on #8950, whose stored type shapes it reads.

Interface inclusion ignored @notUndefined. An interface could declare @notUndefined type t over type t = option<int>, and clients then unboxed option<t>, so Some(M.none) was None.

Now, when an interface (or a functor parameter, or the original of a with type constraint) declares a type @notUndefined, its implementation must be a type that cannot hold undefined. The rule is the one option unboxing uses: a record, a builtin type other than option/unit, an annotated or @notUndefined type, and so on. An implementation whose representation the compiler does not see is accepted too, because it has none to contradict the claim: an abstract type, also through an abbreviation such as type t = Dom.element, or a JavaScript object type. The stdlib's Lazy.res (type t<+'a> under @notUndefined in Lazy.resi) relies on that, and so do bindings. The type of a functor parameter or a locally abstract type stands for any type and does not count as abstract.

Type declarations do not match:
  type t = option<int>
is not included in
  type t
Their runtime representations differ:
the second declaration is @notUndefined,
but the first declaration can hold undefined.

The check sits in Includemod.type_declarations, which every inclusion goes through, because Typeopt depends on Includecore through Typedecl. UntaggedVariants.md rule 13 now states it.

New tests: super_errors_multi/Cross_not_undefined_option_implementation (the issue's repro), super_errors/not_undefined_functor_argument and not_undefined_functor_parameter, and tests/tests/src/not_undefined_interface_test.res (an abbreviation of an abstract type and an object type, accepted).

🤖 Generated with Claude Code

https://claude.ai/code/session_01Fc7LEuyEfynjaKCVEBPmTF

@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

@codecov

codecov Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.50000% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.31%. Comparing base (31a67fe) to head (743b80b).
⚠️ Report is 2 commits behind head on instanceof-typeof-annotations.

Files with missing lines Patch % Lines
compiler/ml/includemod.ml 86.66% 2 Missing ⚠️
Additional details and impacted files
@@                      Coverage Diff                       @@
##           instanceof-typeof-annotations    #8966   +/-   ##
==============================================================
  Coverage                          81.31%   81.31%           
==============================================================
  Files                                456      456           
  Lines                              64130    64145   +15     
==============================================================
+ Hits                               52148    52161   +13     
- Misses                             11982    11984    +2     
Files with missing lines Coverage Δ
compiler/ml/includecore.ml 79.06% <100.00%> (+0.07%) ⬆️
compiler/ml/includemod.ml 76.30% <86.66%> (+0.42%) ⬆️
🚀 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.

@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 🔄 Running since 2026-10-23T11:48:15.038351Z b19d240 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.

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

@rescript/belt

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

@rescript/darwin-arm64

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

@rescript/linux-arm64

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

@rescript/linux-x64

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

@rescript/runtime

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

@rescript/win32-x64

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

commit: ecc1ff2

Base automatically changed from untagged-no-hardcoded-classes to instanceof-typeof-annotations October 23, 2026 11:16
@cknitt
cknitt force-pushed the not-undefined-inclusion branch from df024f4 to b19d240 Compare October 23, 2026 11:47
@cknitt

cknitt commented Oct 23, 2026

Copy link
Copy Markdown
Member Author

@codex review

@rescript-lang rescript-lang deleted a comment from github-actions Bot Oct 23, 2026
@rescript-lang rescript-lang deleted a comment from github-actions Bot Oct 23, 2026
@rescript-lang rescript-lang deleted a comment from github-actions Bot Oct 23, 2026
@cknitt
cknitt force-pushed the not-undefined-inclusion branch from b19d240 to f8ee511 Compare October 23, 2026 17:28
@github-actions

github-actions Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Changelog

🐛 Bug fix

  • Reject an implementation that can hold undefined for a type its interface declares @notUndefined, which made Some(None) of the type None in clients.

Semantic Diff

3 commits · Full page

✨ Simplify the payload kinds and untagged checks

31a67fe · Christoph Knittel + Claude · Oct 24, 2026, 09:16 UTC · new

Why

The architecture review found the same knowledge in several places: three tables of typeof names, an IntType/FloatType distinction that no consumer made, two functions for @unboxed, two checks of the arity of untagged constructors, and the opaque-abstract-type test written inline.

Variant_runtime lists the kinds typeof recognizes once, and the names, the reverse lookup and the generated typeof strings come from it. int and float payloads have one kind, NumberType. has_untagged replaces its twin, the layout alone reports a constructor with more than one argument, now at the constructor, and Variant_layout.abstractness says whether an abstract type can be refined. resolve_shapes stops when a round changes nothing, type_cannot_contain_undefined no longer re-reads a shape it has read, and the doc of type_shape and rule 14 say what the shape holds and how classes compare.

Diagnostic modified (more than one argument)

@unboxed
type t = A(int, int) | B(string)

diagnostic

-UntaggedConstructorMoreThanOneArg.res:2:1-32
+UntaggedConstructorMoreThanOneArg.res:2:10-20
Check @notUndefined of an interface against the implementation

b767069 · cknitt + Claude · Oct 22, 2026, 19:04 UTC

Why

Interface inclusion ignored @notUndefined. An interface could declare @notUndefined type t over type t = option<int>, and clients then unboxed option<t>, so Some(M.none) was None (#8964).

When the second declaration of an inclusion is @notUndefined, the first must be a type that cannot hold undefined, by the rule option unboxing uses, or an abstract type, which has no representation to contradict it. This covers .resi files, functor arguments and with type constraints. The check runs in Includemod, since Typeopt depends on Includecore through Typedecl.

Diagnostic added

// Hidden.resi
@notUndefined type t
// Hidden.res
type t = option<int>

diagnostic · After

Type declarations do not match:
  type t = option<int>
is not included in
  type t
Their runtime representations differ:
the second declaration is @notUndefined,
but the first declaration can hold undefined.

Review

  • Why accept an abstract implementation without the attribute? The stdlib's Lazy.res declares type t<+'a> under @notUndefined in its interface, and so may bindings; such a type has no representation that could hold undefined, so requiring the attribute twice would only break them.
Accept @notUndefined over implementations the compiler cannot see into

743b80b · cknitt + Claude · Oct 23, 2026, 11:34 UTC

Why

The check rejected bindings that are fine: an interface's @notUndefined type t over type t = Dom.element, an abbreviation of an abstract type, or over a JavaScript object type, which is never undefined.

An implementation whose representation the compiler does not see has none that contradicts the interface: an abstract type, also reached through an abbreviation, or a JavaScript object type. A functor parameter's or a locally abstract type stands for any type and does not count as abstract.

Diagnostic removed (an abbreviation of an abstract type)

// A.resi
@notUndefined type t
// A.res
type t = Dom.element

diagnostic · Before

Their runtime representations differ:
the second declaration is @notUndefined,
but the first declaration can hold undefined.

@cknitt
cknitt force-pushed the not-undefined-inclusion branch from f8ee511 to a1622ab Compare October 23, 2026 17:31
@cknitt
cknitt force-pushed the not-undefined-inclusion branch 2 times, most recently from 25fc167 to ecc1ff2 Compare October 24, 2026 08:56
cknitt and others added 2 commits October 24, 2026 09:16
Why:
    Interface inclusion ignored `@notUndefined`. An interface could declare
    `@notUndefined type t` over `type t = option<int>`, and clients then
    unboxed `option<t>`, so `Some(M.none)` was `None` (#8964).

When the second declaration of an inclusion is `@notUndefined`, the first
must be a type that cannot hold `undefined`, by the rule `option` unboxing
uses, or an abstract type, which has no representation to contradict it.
This covers `.resi` files, functor arguments and `with type` constraints.
The check runs in `Includemod`, since `Typeopt` depends on `Includecore`
through `Typedecl`.

Diagnostic added:
Example:
    // Hidden.resi
    @notundefined type t
    // Hidden.res
    type t = option<int>
After (diagnostic):
    Type declarations do not match:
      type t = option<int>
    is not included in
      type t
    Their runtime representations differ:
    the second declaration is @notundefined,
    but the first declaration can hold undefined.

Review: Why accept an abstract implementation without the attribute? The
stdlib's `Lazy.res` declares `type t<+'a>` under `@notUndefined` in its
interface, and so may bindings; such a type has no representation that could
hold `undefined`, so requiring the attribute twice would only break them.

Fixes: #8964
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 check rejected bindings that are fine: an interface's
    `@notUndefined type t` over `type t = Dom.element`, an abbreviation of an
    abstract type, or over a JavaScript object type, which is never
    undefined.

An implementation whose representation the compiler does not see has none
that contradicts the interface: an abstract type, also reached through an
abbreviation, or a JavaScript object type. A functor parameter's or a locally
abstract type stands for any type and does not count as abstract.

Diagnostic removed (an abbreviation of an abstract type):
Example:
    // A.resi
    @notundefined type t
    // A.res
    type t = Dom.element
Before (diagnostic):
    Their runtime representations differ:
    the second declaration is @notundefined,
    but the first declaration can hold undefined.

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 not-undefined-inclusion branch from ecc1ff2 to 743b80b Compare October 24, 2026 09:18
@cknitt
cknitt merged commit 743b80b into master Oct 24, 2026
14 of 17 checks passed
@cknitt
cknitt deleted the not-undefined-inclusion branch October 24, 2026 09:24
@cknitt

cknitt commented Oct 24, 2026

Copy link
Copy Markdown
Member Author

Folded into #8950: the @notUndefined inclusion check reads the stored type_shape of #8950 and lands with it. GitHub shows this PR as merged because #8950's branch now contains its commits; nothing reached master. #8964 is fixed by #8950.

cknitt added a commit that referenced this pull request Oct 25, 2026
…#8950)

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

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>

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

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>

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

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>

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

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>

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

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>

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

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>

* Recognize untagged payloads only by annotations and builtin types

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>

* Test that payloads are not recognized by type name

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>

* Drop the name list from the untagged variants spec

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>

* Store the resolved runtime shape on type declarations

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

* Close the gaps the re-review of runtime annotations found

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>

* Simplify the payload kinds and untagged checks

Why:
    The architecture review found the same knowledge in several places: three
    tables of `typeof` names, an `IntType`/`FloatType` distinction that no
    consumer made, two functions for `@unboxed`, two checks of the arity of
    untagged constructors, and the opaque-abstract-type test written inline.

`Variant_runtime` lists the kinds `typeof` recognizes once, and the names,
the reverse lookup and the generated `typeof` strings come from it. `int` and
`float` payloads have one kind, `NumberType`. `has_untagged` replaces its
twin, the layout alone reports a constructor with more than one argument, now
at the constructor, and `Variant_layout.abstractness` says whether an
abstract type can be refined. `resolve_shapes` stops when a round changes
nothing, `type_cannot_contain_undefined` no longer re-reads a shape it has
read, and the doc of `type_shape` and rule 14 say what the shape holds and
how classes compare.

Diagnostic modified (more than one argument):
Example:
    @unboxed
    type t = A(int, int) | B(string)
Before (diagnostic):
    UntaggedConstructorMoreThanOneArg.res:2:1-32
After (diagnostic):
    UntaggedConstructorMoreThanOneArg.res:2:10-20

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>

* Check @notundefined of an interface against the implementation

Why:
    Interface inclusion ignored `@notUndefined`. An interface could declare
    `@notUndefined type t` over `type t = option<int>`, and clients then
    unboxed `option<t>`, so `Some(M.none)` was `None` (#8964).

When the second declaration of an inclusion is `@notUndefined`, the first
must be a type that cannot hold `undefined`, by the rule `option` unboxing
uses, or an abstract type, which has no representation to contradict it.
This covers `.resi` files, functor arguments and `with type` constraints.
The check runs in `Includemod`, since `Typeopt` depends on `Includecore`
through `Typedecl`.

Diagnostic added:
Example:
    // Hidden.resi
    @notundefined type t
    // Hidden.res
    type t = option<int>
After (diagnostic):
    Type declarations do not match:
      type t = option<int>
    is not included in
      type t
    Their runtime representations differ:
    the second declaration is @notundefined,
    but the first declaration can hold undefined.

Review: Why accept an abstract implementation without the attribute? The
stdlib's `Lazy.res` declares `type t<+'a>` under `@notUndefined` in its
interface, and so may bindings; such a type has no representation that could
hold `undefined`, so requiring the attribute twice would only break them.

Fixes: #8964
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>

* Accept @notundefined over implementations the compiler cannot see into

Why:
    The check rejected bindings that are fine: an interface's
    `@notUndefined type t` over `type t = Dom.element`, an abbreviation of an
    abstract type, or over a JavaScript object type, which is never
    undefined.

An implementation whose representation the compiler does not see has none
that contradicts the interface: an abstract type, also reached through an
abbreviation, or a JavaScript object type. A functor parameter's or a locally
abstract type stands for any type and does not count as abstract.

Diagnostic removed (an abbreviation of an abstract type):
Example:
    // A.resi
    @notundefined type t
    // A.res
    type t = Dom.element
Before (diagnostic):
    Their runtime representations differ:
    the second declaration is @notundefined,
    but the first declaration can hold undefined.

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>

* Require a private abbreviation for every subclass

Why:
    A public abbreviation could declare a subclass when its arguments were
    not all type variables, meant for phantom type hierarchies. The test
    was syntactic, so `@instanceOf("Dog") type dog = animal<int>` passed,
    and every `animal<int>` was then a dog without a coercion.

A subclass is always a private abbreviation, phantom hierarchies included;
upcasts use `:>`.

Diagnostic added (public subclass of an instantiated class):
Example:
    @instanceof("Animal") type animal<'a>
    @instanceof("Dog") type dog = animal<int>
Before (diagnostic): none
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.

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

* Keep callable instances out of a function arm

Why:
    A class can extend Function, so its instances have typeof "function".
    The instance cases without an arm were only checked before the typeof
    switch when it had an object case, so a callable instance falling to
    the default was taken by a function arm.

The instance cases without an arm are now checked whenever the typeof
switch has an object or a function case.

Generated code changed (a callable class in the default):
Example:
    @instanceof("Callable") type callable
    @unboxed
    type t = C(callable) | D(animal) | F(int => int) | S(string) | N(float) | B(bool)
    let f = v => switch v { | F(_) => "function" | S(_) => "string" | _ => "other" }
Before (JS):
    switch (typeof v) {
      case "function" :
        return "function";
      case "string" :
        return "string";
      default:
        return "other";
    }
After (JS):
    if (v instanceof Animal || v instanceof Callable) {
      return "other";
    }
    switch (typeof v) {
      case "function" :
        return "function";
      case "string" :
        return "string";
      default:
        return "other";
    }

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

---------

Signed-off-by: Christoph Knittel <ck@cca.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

@notUndefined in an interface is not checked against the implementation

1 participant