Skip to content

TScert: print nested options by depth and polymorphic option code as overloads under -bs-emit-ts - #9213

Draft
cristianoc wants to merge 3 commits into
exp/emit-ts-2from
exp/emit-ts-nested-2
Draft

cristianoc wants to merge 3 commits into
exp/emit-ts-2from
exp/emit-ts-nested-2

Conversation

@cristianoc

Copy link
Copy Markdown
Collaborator

Stacked on #9211. It prints nested options and polymorphic option code under -bs-emit-ts so that tsc and TScert accept the nested matches #9211 lists as open.

What is printed:

  • Nested options by depth. option<t> prints t beside the marker of each depth Some(v) can take. For example, option<option<int>> prints undefined | number | { readonly BS_PRIVATE_NESTED_SOME_NONE: 0 }, and each further level adds the next depth.
  • Depth printed as number at abstract types, at a type declaration's parameter, and at a payload that may own the marker key. That covers a record with a field @as("BS_PRIVATE_NESTED_SOME_NONE"), an object type, and a variant whose payload may own the key.
  • Polymorphic option code as overloads. A function whose type parameter is an option payload prints one overload per nesting depth, each its own signature at 'a := option^k<a>, with a extends string | number | boolean | bigint. The implementation's signature is the deepest instance.

Runtime declarations (runtime-dts/): Primitive_option.some and valFromOption, and Stdlib_Option.getOr, are stated at depths 0–2. Each line is checked against Primitive_option.res or Stdlib_Option.res, and none assumes anything about who owns the marker key.

Tests:

  • New examples NestMatch (inner, useNested) and Memo (cached).
  • tsc.txt is now empty: the four errors at the primitive bound are gone.
  • make test-gentype passes with its outputs unchanged.

TScert: with its generic-overloads change, all 10 emit_ts modules verify end to end. TScert is not released yet, so a reviewer can't reproduce that run.

Limit: polymorphic option code at a record payload, or nested deeper than the printed overloads, still fails tsc and is refused, never falsely accepted. Covering every payload needs either an assumption that only the runtime's markers own the key, or conditional types.

🤖 Generated with Claude Code

cristianoc and others added 3 commits November 4, 2026 09:16
Why:
    A nested match and a polymorphic function at a nested option failed
    tsc under -bs-emit-ts. The marker arm printed every depth as `number`,
    `{ readonly BS_PRIVATE_NESTED_SOME_NONE: number }`, so no true
    declaration of `valFromOption` gave the payload back: at depth 0 it
    returns `undefined`, at depth d > 0 the marker of depth d - 1. And a
    function whose type parameter is an option's payload was bounded to the
    primitives, so `getOr` at `'a = option<int>` (NestMatch.useNested) and
    `Stdlib_Option.getOr` at that instance (Memo.cached) had no signature.

Representation modified (option under -bs-emit-ts):
Before (representation):
    option<option<int>> ↦ undefined | number | { readonly BS_PRIVATE_NESTED_SOME_NONE: number }
    option<unit> ↦ undefined | void | { readonly BS_PRIVATE_NESTED_SOME_NONE: number }
After (representation):
    option<option<int>> ↦ undefined | number | { readonly BS_PRIVATE_NESTED_SOME_NONE: 0 }
    option<option<option<int>>> ↦ undefined | number | { …: 0 } | { …: 1 }
    option<unit> ↦ undefined | void | { readonly BS_PRIVATE_NESTED_SOME_NONE: 0 }
    option<'a> in a type declaration, or at an abstract payload ↦ undefined | a | { …: number }
    rule: Some(v) is the marker of depth 0 when v is undefined, of depth d + 1 when v is the marker of depth d, and v otherwise (Primitive_option.some); the depths are read off the payload's encoding, an option's, unit's, or a type that cannot hold undefined (no marker)

JS modified (-bs-emit-ts polymorphic option code):
Example:
    let getOr = (o: option<'a>, d: 'a) =>
      switch o {
      | Some(v) => v
      | None => d
      }
Before (js):
    export function getOr<a extends string | number | boolean | bigint>(o: (undefined | a), d: a): a {
After (js):
    export function getOr<a extends string | number | boolean | bigint>(o: (undefined | a), d: a): a;
    export function getOr<a extends string | number | boolean | bigint>(o: (undefined | (
        (undefined | a)
      | { readonly BS_PRIVATE_NESTED_SOME_NONE: 0 })), d: (undefined | a)): (undefined | a);
    export function getOr<a extends string | number | boolean | bigint>(o: (undefined | (
        (undefined | a)
      | { readonly BS_PRIVATE_NESTED_SOME_NONE: 0 })), d: (undefined | a)): (undefined | a) {

JS modified (-bs-emit-ts a function returning undefined alone):
Example:
    let useNestedFolded = () => getOr(Some(None), Some(3))
Before (js):
    export function useNestedFolded(): (undefined | number) {
    }
After (js):
    export function useNestedFolded(): (undefined | number) {
      return;
    }

Review: Why is each overload true? It is the function's signature at the
instance `'a := option^k<a>`, k = 0 or 1, with `a` bounded to the primitives
as before, printed by the same rule: the function's code is the code of
every instance. The signature the body carries is the deepest instance's,
which no caller sees; tsc checks the body there.

Review: Why no conditional type for `Some<A>`? `Some<A>` at an unbounded
`A` is true only if no value but the runtime's markers owns the key
`BS_PRIVATE_NESTED_SOME_NONE`; a record type admits values owning further
keys. The overloads hold at primitive payloads with no assumption.

Review: What do the runtime declarations state? `Primitive_option.some` and
`valFromOption`, and `Stdlib_Option.getOr`, one signature per nesting of a
primitive payload, up to an option of an option; each line audited against
Primitive_option.res and Stdlib_Option.res. `Stdlib_Option.map` keeps its
primitive signature.

Review: Does this change genType's output? No: the marker hook answers "no
marker" outside -bs-emit-ts, and the gentype tests' outputs are unchanged.

The examples NestMatch (nested matches, `getOr` at a nested instance) and
Memo (`Stdlib_Option.getOr` at `'a = option<int>`) join the build test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Cristiano Calcagno <ccrisccris@gmail.com>
Why:
    The depth rule read "cannot hold undefined" as "is no marker". A record
    naming a field `BS_PRIVATE_NESTED_SOME_NONE` at run time satisfies the
    first and owns the key `Primitive_option.some` reads: with
    `x = {BS_PRIVATE_NESTED_SOME_NONE: 0, tag: "payload"}`, `Some(x)` at
    `option<option<keyed>>` is the marker of depth 1, which the printed
    `undefined | keyed | { …: 0 }` excludes (a review found it and ran it).

Representation modified (option under -bs-emit-ts, payloads that may own the key):
Before (representation):
    option<option<keyed>> ↦ undefined | keyed | { readonly BS_PRIVATE_NESTED_SOME_NONE: 0 }
After (representation):
    option<option<keyed>> ↦ undefined | keyed | { readonly BS_PRIVATE_NESTED_SOME_NONE: number }
    rule: a payload is no marker only where it cannot hold undefined and may own no key BS_PRIVATE_NESTED_SOME_NONE: a record or an inline record naming that field at run time, an object type, and a variant case whose payload may, may own it

Review: Does the example fail tsc? `keepKeyed` passes the value through and
type-checks; wrapping such a payload calls `Primitive_option.some`, which no
runtime declaration states at a non-primitive payload, and fails tsc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Cristiano Calcagno <ccrisccris@gmail.com>
Why:
    Printing nested options by depth and polymorphic option code as
    overloads gives NestMatch's nested match and its getOr at option<int>
    a signature tsc accepts, so the four errors at the primitive bound are
    gone.

Tests modified (tests/build_tests/emit_ts/tsc.txt):
    empty

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown

Changelog

No changelog entry.

Semantic Diff

3 commits · Full page

Print nested options by depth and polymorphic option code as overloads

7c88be8 · cristianoc + Claude · Nov 04, 2026, 06:01 UTC

Why

A nested match and a polymorphic function at a nested option failed tsc under -bs-emit-ts. The marker arm printed every depth as number, { readonly BS_PRIVATE_NESTED_SOME_NONE: number }, so no true declaration of valFromOption gave the payload back: at depth 0 it returns undefined, at depth d > 0 the marker of depth d - 1. And a function whose type parameter is an option's payload was bounded to the primitives, so getOr at 'a = option<int> (NestMatch.useNested) and Stdlib_Option.getOr at that instance (Memo.cached) had no signature.

Representation modified (option under -bs-emit-ts)

representation

-option<option<int>>                                         ↦ undefined | number | { readonly BS_PRIVATE_NESTED_SOME_NONE: number }
-option<unit>                                                ↦ undefined | void | { readonly BS_PRIVATE_NESTED_SOME_NONE: number }
+option<option<int>>                                         ↦ undefined | number | { readonly BS_PRIVATE_NESTED_SOME_NONE: 0 }
+option<option<option<int>>>                                 ↦ undefined | number | { …: 0 } | { …: 1 }
+option<unit>                                                ↦ undefined | void | { readonly BS_PRIVATE_NESTED_SOME_NONE: 0 }
+option<'a> in a type declaration, or at an abstract payload ↦ undefined | a | { …: number }
+rule: Some(v) is the marker of depth 0 when v is undefined, of depth d + 1 when v is the marker of depth d, and v otherwise (Primitive_option.some); the depths are read off the payload's encoding, an option's, unit's, or a type that cannot hold undefined (no marker)

JS modified (-bs-emit-ts polymorphic option code)

let getOr = (o: option<'a>, d: 'a) =>
  switch o {
  | Some(v) => v
  | None => d
  }

js

-export function getOr<a extends string | number | boolean | bigint>(o: (undefined | a), d: a): a {
+export function getOr<a extends string | number | boolean | bigint>(o: (undefined | a), d: a): a;
+export function getOr<a extends string | number | boolean | bigint>(o: (undefined | (
+    (undefined | a)
+  | { readonly BS_PRIVATE_NESTED_SOME_NONE: 0 })), d: (undefined | a)): (undefined | a);
+export function getOr<a extends string | number | boolean | bigint>(o: (undefined | (
+    (undefined | a)
+  | { readonly BS_PRIVATE_NESTED_SOME_NONE: 0 })), d: (undefined | a)): (undefined | a) {

JS modified (-bs-emit-ts a function returning undefined alone)

let useNestedFolded = () => getOr(Some(None), Some(3))

js

 export function useNestedFolded(): (undefined | number) {
+  return;
 }

The examples NestMatch (nested matches, getOr at a nested instance) and Memo (Stdlib_Option.getOr at 'a = option<int>) join the build test.

Review

  • Why is each overload true? It is the function's signature at the instance 'a := option^k<a>, k = 0 or 1, with a bounded to the primitives as before, printed by the same rule: the function's code is the code of every instance. The signature the body carries is the deepest instance's, which no caller sees; tsc checks the body there.
  • Why no conditional type for Some<A>? Some<A> at an unbounded A is true only if no value but the runtime's markers owns the key BS_PRIVATE_NESTED_SOME_NONE; a record type admits values owning further keys. The overloads hold at primitive payloads with no assumption.
  • What do the runtime declarations state? Primitive_option.some and valFromOption, and Stdlib_Option.getOr, one signature per nesting of a primitive payload, up to an option of an option; each line audited against Primitive_option.res and Stdlib_Option.res. Stdlib_Option.map keeps its primitive signature.
  • Does this change genType's output? No: the marker hook answers "no marker" outside -bs-emit-ts, and the gentype tests' outputs are unchanged.
Print a marker of any depth at a payload that may own the marker key

bd978bd · cristianoc + Claude · Nov 04, 2026, 06:11 UTC

Why

The depth rule read "cannot hold undefined" as "is no marker". A record naming a field BS_PRIVATE_NESTED_SOME_NONE at run time satisfies the first and owns the key Primitive_option.some reads: with x = {BS_PRIVATE_NESTED_SOME_NONE: 0, tag: "payload"}, Some(x) at option<option<keyed>> is the marker of depth 1, which the printed undefined | keyed | { …: 0 } excludes (a review found it and ran it).

Representation modified (option under -bs-emit-ts, payloads that may own the key)

representation

-option<option<keyed>> ↦ undefined | keyed | { readonly BS_PRIVATE_NESTED_SOME_NONE: 0 }
+option<option<keyed>> ↦ undefined | keyed | { readonly BS_PRIVATE_NESTED_SOME_NONE: number }
+rule: a payload is no marker only where it cannot hold undefined and may own no key BS_PRIVATE_NESTED_SOME_NONE: a record or an inline record naming that field at run time, an object type, and a variant case whose payload may, may own it

Review

  • Does the example fail tsc? keepKeyed passes the value through and type-checks; wrapping such a payload calls Primitive_option.some, which no runtime declaration states at a non-primitive payload, and fails tsc.
Record tsc's diagnostics for the emit_ts examples: none with overloads

dc30f4e · cristianoc + Claude · Nov 04, 2026, 08:16 UTC

Why

Printing nested options by depth and polymorphic option code as overloads gives NestMatch's nested match and its getOr at option<int> a signature tsc accepts, so the four errors at the primitive bound are gone.

Tests modified (tests/build_tests/emit_ts/tsc.txt)

empty

@cristianoc
cristianoc added this pull request to stack #9214 November 4, 2026 08:43
@codecov

codecov Bot commented Nov 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.20805% with 28 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.75%. Comparing base (8a1bb7e) to head (dc30f4e).

Files with missing lines Patch % Lines
compiler/core/js_ts_annot.ml 78.51% 26 Missing ⚠️
compiler/core/js_dump.ml 95.23% 1 Missing ⚠️
compiler/gentype/translate_type_expr_from_types.ml 83.33% 1 Missing ⚠️
Additional details and impacted files
@@                Coverage Diff                @@
##           exp/emit-ts-2    #9213      +/-   ##
=================================================
- Coverage          81.76%   81.75%   -0.01%     
=================================================
  Files                459      459              
  Lines              65480    65613     +133     
=================================================
+ Hits               53539    53645     +106     
- Misses             11941    11968      +27     
Files with missing lines Coverage Δ
compiler/gentype/gentype_config.ml 77.61% <100.00%> (ø)
compiler/core/js_dump.ml 93.78% <95.23%> (+0.02%) ⬆️
compiler/gentype/translate_type_expr_from_types.ml 88.69% <83.33%> (-0.16%) ⬇️
compiler/core/js_ts_annot.ml 80.87% <78.51%> (-3.04%) ⬇️
🚀 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.

@pkg-pr-new

pkg-pr-new Bot commented Nov 4, 2026

Copy link
Copy Markdown

Open in StackBlitz

rescript

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

@rescript/belt

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

@rescript/darwin-arm64

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

@rescript/linux-arm64

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

@rescript/linux-x64

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

@rescript/runtime

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

@rescript/win32-x64

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

commit: dc30f4e

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant