Semantic Diff

#9211 TScert: print TypeScript for verification under experimental -bs-emit-ts

8 commits

6741bab07e473f7dbec3e56efc6a807dee8eea28..pr-9211

571a0cc5e · cristianoc + Claude · Nov 04, 2026, 01:34 UTC

Print TypeScript for a module under the experimental -bs-emit-ts flag

Why

A feasibility study for checking ReScript output with TScert, which reads TypeScript whose top-level functions annotate their parameters and result. The flag writes .res.ts (.mts for .mjs) in place of the JavaScript output, annotates each top-level function from the typed tree, and puts the module's type declarations at the top through genType's translation. The annotations are keyed by Ident.t (name and stamp), so one reaches the output only when the J IR prints the identifier the typed tree bound; RESCRIPT_EMIT_TS_DEBUG=1 reports, per printed top-level function, whether its stamp and its parameters' stamps are the typed tree's.

modifiedJS top-level functions under -bs-emit-ts

type point = {x: int, y: int, label: string}let moveX = (p: point, dx: int) => {...p, x: p.x + dx}
js
Before
After Rec.res.ts
function moveX(p, dx) { export type point = { readonly x: number; readonly y: number; readonly label: string}; function moveX(p: point, dx: number): point { return { return { x: p.x + dx | 0, x: p.x + dx | 0, y: p.y, y: p.y, label: p.label label: p.label }; };}}

Review

  • Do the stamps survive to the printer? For every binding let f = (…) => … at the top level or in a nested module, uncurried, inlined into its callers, private or exported, the printed function and each of its parameters carry the typed tree's stamp. A function the optimizer synthesizes, such as let partial = curried(1), has fresh parameters and is printed unannotated.
8ec2de484 · cristianoc + Claude · Nov 04, 2026, 01:36 UTC

Print an exported top-level function as export function under -bs-emit-ts

Why

TScert's source fragment admits export function f(…) and refuses the trailing export list export { f, … }. Under -bs-emit-ts a top-level function exported under its own name is printed with export in front and leaves the list; other exports, such as a module record or an alias, stay in it. The printer is assumed to print a top-level function under its source name.

modifiedJS exports under -bs-emit-ts

let add = (a: int, b: int) => a + b
js
Before
After
function add(a: number, b: number): number {export function add(a: number, b: number): number { return a + b | 0; return a + b | 0;}} export { add,}
2a07f4e04 · cristianoc + Claude · Nov 04, 2026, 02:50 UTC

Print type parameters and optional parameters under -bs-emit-ts

Why

A polymorphic function printed its type variables without declaring them, which tsc rejects, and each parameter's variables were named on their own, so distinct variables shared the name T1. Optional labelled arguments printed as mandatory undefined | T parameters.

modifiedJS -bs-emit-ts type parameters

let id = x => xlet second = (_x: 'a, y: 'b): 'b => y
js
Before
After
export function id(x: T1): T1 {export function id<T1>(x: T1): T1 { return x; return x;}}export function second(_x: T1, y: T1): T1 {export function second<a, b>(_x: a, y: b): b { return y; return y;}}

modifiedJS -bs-emit-ts optional labelled arguments

let scale = (x: int, ~by=2) => x * bylet offset = (~from=0, x: int) => x - from
js
Before
After
export function scale(x: number, byOpt: (undefined | number)): number {export function scale(x: number, byOpt?: number): number {……export function offset(fromOpt: (undefined | number), x: number): number {export function offset(fromOpt: (undefined | number), x: number): number {

Review

  • Why does fromOpt keep undefined | number? TypeScript requires every parameter after an optional one to be optional, so ? goes only on the optional arguments after the last mandatory one; a ReScript call passes undefined for an omitted argument in either position.
ed4458a38 · cristianoc + Claude · Nov 04, 2026, 02:54 UTC

Check in the -bs-emit-ts examples and their TypeScript output

The build test tests/build_tests/emit_ts compiles seven examples with -bs-emit-ts and type-checks the checked-in .res.ts output with tsc --strict against declarations for the runtime functions the output imports.

beb62847a · cristianoc + Claude · Nov 04, 2026, 04:32 UTC

Print option types true of the nested-option encoding under -bs-emit-ts

Why

option<t> printed undefined | t, but Some(v) is a marker object {BS_PRIVATE_NESTED_SOME_NONE: depth} when v is undefined or a marker, so the type was false for option<option<int>> and option<unit>. At a type parameter it was false at a nested instantiation, and the runtime declarations valFromOption<A>(x: A): A, getOr and map were false there: at A the marker type, valFromOption(some(undefined)) returns undefined. A verifier taking those declarations accepted programs returning wrong values.

modifiedRepresentation option under -bs-emit-ts

representation
Before
After
option<int> ↦ undefined | numberoption<int> ↦ undefined | numberoption<option<int>> ↦ undefined | (undefined | number)option<'a> ↦ undefined | a option<option<int>> ↦ undefined | (undefined | number) | { readonly BS_PRIVATE_NESTED_SOME_NONE: number }option<'a> ↦ undefined | a, with a extends string | number | boolean | bigintoption<'a> in a type declaration ↦ undefined | a | { readonly BS_PRIVATE_NESTED_SOME_NONE: number }
rulethe marker arm is printed where Typeopt.typecannotcontainundefined fails for the payload, the test that selects Psome over Psomenotnest

modifiedJS -bs-emit-ts type parameters

let getOr = (o, d) =>  switch o {  | Some(v) => v  | None => d  }
js
Before
After
export function getOr<T1>(o: (undefined | T1), d: T1): T1 {export function getOr<T1 extends string | number | boolean | bigint>(o: (undefined | T1), d: T1): T1 {

modifiedJS -bs-emit-ts nested options

type box<'a> = {v: option<'a>}let keep = (o: option<option<int>>) => o
js
Before
After
export type box<a> = { readonly v: (undefined | a) };export function keep(o: (undefined | (undefined | number))): (undefined | (undefined | number)) { export type box<a> = { readonly v: (undefined | ( a | { readonly BS_PRIVATE_NESTED_SOME_NONE: number })) };export function keep(o: (undefined | ( (undefined | number) | { readonly BS_PRIVATE_NESTED_SOME_NONE: number }))): …

The runtime declarations in runtime-dts state the same bound, each line audited against Primitive_option.res and Stdlib_Option.res, and Stdlib_Array.reduce's callback receives the index and the array that Array.prototype.reduce passes. The Nest example shows the marker arm and the bound.

Review

  • Why bound a type parameter by the primitives, and not by every type without the marker key? some(x) and valFromOption(x) return x exactly when x is not undefined and its key BS_PRIVATE_NESTED_SOME_NONE reads undefined. A primitive owns no keys and its untampered prototypes hold none. A TypeScript record type admits values owning further keys, and a weak type { BS_PRIVATE_NESTED_SOME_NONE?: undefined } rejects every record that does not state that field, so no wider bound is both true and usable.
  • What does the bound refuse? A call of such a function at a non-primitive instantiation, getOr of an option<point> say, fails tsc. A nested match such as Some(Some(n)) fails tsc too: the marker arm erases the depth, so no true declaration of valFromOption returns the payload type there.
ccb1f14e7 · cristianoc + Claude · Nov 04, 2026, 04:39 UTC

Print the marker arm of option in aliases and optional arguments of arrows

Why

The marker arm reached only options genType translated from a type expression as option<t>. A type declaration's manifest, translated from its core type, and an optional argument of a function type, whose option genType rebuilds, still printed undefined | t for a payload that can hold a marker. An optional argument of a function type in a type declaration also lost its option: the core type of ~x: int=? is its payload int, which genType read as the whole argument.

modifiedJS -bs-emit-ts type declarations

type opt<'a> = option<'a>type callback = (~x: option<int>=?) => inttype cb2 = (~x: int=?) => int
js
Before
After
export type opt<a> = (undefined | a);export type callback = (x:(undefined | number)) => number;export type cb2 = (x:number) => number; export type opt<a> = (undefined | ( a | { readonly BS_PRIVATE_NESTED_SOME_NONE: number }));export type callback = (x:(undefined | ( (undefined | number) | { readonly BS_PRIVATE_NESTED_SOME_NONE: number }))) => number;export type cb2 = (x:(undefined | number)) => number;

Review

  • Does this change genType's own output? Only where a type declaration has an optional argument in a function type, which genType printed without its undefined; the marker arm is off outside -bs-emit-ts, and the gentype tests' outputs are unchanged.
b0a9c17b2 · cristianoc + Claude · Nov 04, 2026, 05:12 UTC

Print a function body that is only return; under -bs-emit-ts

Why

The printer drops a lone return;, so such a function prints an empty body. TypeScript requires a return in a function whose declared result type is other than undefined, void or any, and rejects the empty body with TS2355.

modifiedJS -bs-emit-ts body that is only return;

let none = (): option<int> => None
js
Before
After
export function none(): (undefined | number) {export function none(): (undefined | number) { return;}}

Review

  • Does JavaScript output change? No: the statement is kept only under -bs-emit-ts.
8a1bb7e7f · cristianoc + Claude · Nov 04, 2026, 05:12 UTC

Record tsc's diagnostics for the emit_ts examples, with nested-option unwrapping

The emit_ts test writes what tsc --strict reports for the generated TypeScript to a checked-in tsc.txt, so an example the printed types refuse is checked in with its diagnostics. NestMatch unwraps nested options: a nested match and the generic getOr at 'a = option<int>, which the primitive bound on Primitive_option refuses.