Skip to content

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

Draft
cknitt wants to merge 2 commits into
instanceof-typeof-annotationsfrom
not-undefined-inclusion
Draft

cknitt wants to merge 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
@github-actions

Copy link
Copy Markdown

Semantic Diff

1 commit · Full page

Check @notUndefined of an interface against the implementation

5f2e4de · Christoph Knittel with Claude Opus 5.5 · Oct 22, 2026

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.

@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 85.00000% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.27%. Comparing base (330fc49) to head (b19d240).

Files with missing lines Patch % Lines
compiler/ml/includemod.ml 84.21% 3 Missing ⚠️
Additional details and impacted files
@@                      Coverage Diff                       @@
##           instanceof-typeof-annotations    #8966   +/-   ##
==============================================================
  Coverage                          81.27%   81.27%           
==============================================================
  Files                                456      456           
  Lines                              63982    64001   +19     
==============================================================
+ Hits                               52000    52016   +16     
- Misses                             11982    11985    +3     
Files with missing lines Coverage Δ
compiler/ml/includecore.ml 78.18% <100.00%> (+0.07%) ⬆️
compiler/ml/includemod.ml 76.29% <84.21%> (+0.40%) ⬆️
🚀 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.

@github-actions

Copy link
Copy Markdown

Semantic Diff

1 commit · Full page

Check @notUndefined of an interface against the implementation

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

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.

@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: b19d240

Base automatically changed from untagged-no-hardcoded-classes to instanceof-typeof-annotations October 23, 2026 11:16
cknitt and others added 2 commits October 23, 2026 11:42
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 df024f4 to b19d240 Compare October 23, 2026 11:47
@cknitt

cknitt commented Oct 23, 2026

Copy link
Copy Markdown
Member Author

@codex review

@github-actions

Copy link
Copy Markdown

Semantic Diff

2 commits · Full page

Check @notUndefined of an interface against the implementation

34fe539 · Christoph Knittel with Claude Opus 5.5 · Oct 22, 2026

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

b19d240 · Christoph Knittel with Claude Opus 5.5 · Oct 23, 2026

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.

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.

@notUndefined in an interface is not checked against the implementation

1 participant