Skip to content

Commit ab4341f

Browse files
jaggujiclaude
andcommitted
feat: map TS overloads + LiteralUnion | string faithfully
Prefer exact ReScript over a `string` placeholder — two fidelity improvements, plus the optional-callback-param fix they share. - Overloaded functions (intersection of call signatures, e.g. `((reason?: bool|string) => void) & ((e: MouseEvent) => void)`) now become an opaque module with one zero-cost `%identity` accessor view per signature (`asReason`/`asMouse`) instead of silently dropping all but the first overload. - Optional callback params (`(reason?: T) => void`) now map to `option<T> => unit` rather than a required arg. - `LiteralUnion | string` (e.g. `ToastPosition | string`, which TS collapses to bare `string`) recovers the literals from the syntactic AST node and emits an `@unboxed` variant with a `Custom(string)` catch-all (`<base>OrString`) — typo-safe on the knowns, still open. Pure literal unions stay closed enums. Contract: `%identity` is now sanctioned on opaque-module `as*` accessors too (not just `from*`), as the fidelity fallback when `@unboxed` can't express a shape. New golden cases `overload-intersection` and `literal-union-open`; `docs/TYPE_MAPPING.md` + `CLAUDE.md` updated; golden contract linter allows `as*`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0a13f48 commit ab4341f

16 files changed

Lines changed: 447 additions & 19 deletions

File tree

CLAUDE.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ in CI — treat it as a contract, not just docs.
1313
## Hard rules (the contract)
1414

1515
- **No unsafe casts.** Never emit `Obj.magic`, `@unwrap`, or a bare `%identity`. The only allowed
16-
`%identity` is the zero-cost `external from*` constructor of an opaque-type module.
16+
`%identity` is the zero-cost `external from*` constructor **or `as*` accessor** of an opaque-type
17+
module (the value passes through unchanged) — used as the fidelity fallback when an exact type or
18+
`@unboxed` variant can't express the shape (e.g. reverse `as*` views of an overloaded function).
1719
- **Flag, don't fake.** If a type can't be modelled exactly, emit a `string` placeholder + comment
1820
and bucket it (⚪ loose / 🔍 review / 🛑 broken). Never emit a plausible-but-wrong type.
1921
- **Multi-type props**`@unboxed` untagged variant (distinct runtime tags) or an opaque module.

docs/TYPE_MAPPING.md

Lines changed: 60 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,25 @@
88
99
## The contract (read this first)
1010

11-
1. **No unsafe casts.** Never `Obj.magic`, `@unwrap`, or a bare `%identity`. The *only*
12-
permitted `%identity` is the zero-cost `external from*` constructor of an
11+
**Goal: maximum ReScript fidelity.** `%identity` is not something to avoid — used inside an
12+
opaque module it is a principled, zero-cost tool (the value passes straight through unchanged),
13+
exactly as hand-written official bindings use it. Prefer fidelity over a `string` placeholder.
14+
The **fidelity ladder**, best first:
15+
16+
1. an **exact** native ReScript type;
17+
2. an [`@unboxed`](#unboxed-unions) untagged variant when members are runtime-discriminable;
18+
3. an [**opaque-type module**](#opaque-module-unions) with zero-cost `%identity` views — forward
19+
`from*` constructors (concrete→opaque) and/or reverse `as*` accessors (opaque→concrete, e.g. the
20+
per-signature views of an overloaded function) — when `@unboxed` can't express it;
21+
4. only then a `string` placeholder + a bucket flag (⚪ loose / 🔍 review / 🛑 broken).
22+
23+
The rules:
24+
25+
1. **No unsafe casts.** Never `Obj.magic`, `@unwrap`, or a *bare* `%identity`. The *only* permitted
26+
`%identity` is the zero-cost `external from*` constructor **or `as*` accessor** of an
1327
[opaque-type module](#opaque-module-unions), where the value genuinely passes through unchanged.
14-
2. **Flag, don't fake.** If a type can't be modelled exactly, emit a `string` placeholder
15-
with a comment and bucket it (⚪ loose / 🔍 review / 🛑 broken) — never a plausible-but-wrong type.
28+
2. **Flag, don't fake.** If a type can't reach rung 1–3, emit a `string` placeholder with a comment
29+
and bucket it (⚪ loose / 🔍 review / 🛑 broken) — never a plausible-but-wrong type.
1630
3. **Multi-type props** become an [`@unboxed`](#unboxed-unions) untagged variant when the members
1731
have distinct runtime tags (`typeof`/`Array.isArray`), else an [opaque module](#opaque-module-unions).
1832
4. **`unknown` is an opaque value → `JSON.t`**, never a type variable (a type variable in
@@ -47,16 +61,27 @@ Fixture: [`primitives`](../test/golden/cases/primitives)
4761
---
4862

4963
## String-literal unions & enums
50-
Fixture: [`string-enums`](../test/golden/cases/string-enums)
64+
Fixtures: [`string-enums`](../test/golden/cases/string-enums), [`literal-union-open`](../test/golden/cases/literal-union-open)
5165

5266
| TypeScript | ReScript |
5367
|---|---|
5468
| `'sm' \| 'md' \| 'lg'` | `type size = \| @as("sm") Sm \| @as("md") Md \| @as("lg") Lg` |
5569
| `'a' \| 'b' \| (string & {})` | a plain enum — the `string & {}` "open" escape is dropped (this is the `HTMLInputTypeAttribute` shape) |
70+
| `'a' \| 'b' \| string` (plain `\| string` widening) | `@unboxed type <base>OrString = \| @as("a") A \| @as("b") B \| Custom(string)` — the literals as `@as` arms + a `Custom(string)` catch-all. Zero-cost, typo-safe on the known values, still accepts any other string. (TS collapses the union to bare `string`, so the literals are recovered from the **syntactic** node — component-prop level only for now.) |
5671
| real `enum` | same `@as` variant form |
5772

5873
Constructors are PascalCased from the literal (`"icon-only"``IconOnly`, `"2xl"``V2xl`). A
59-
prop literally named `type` becomes `@as("type") ~type_` (reserved-word escaping).
74+
prop literally named `type` becomes `@as("type") ~type_` (reserved-word escaping). The open
75+
`<base>OrString` form is named to match the `boolOrString` / `stringOrNumber` convention, leaving the
76+
bare name free for a co-occurring **pure** (closed) enum of the same literals.
77+
78+
> **`(string & {})` vs `\| string` — both keep the literals, differently.** The branded `(string & {})`
79+
> form preserves its literals at the type level (TS does *not* collapse it) → a **closed** enum. The
80+
> plain `\| string` form *is* collapsed by TS to `string`, so the literals are recovered from the
81+
> syntactic union node → an **open** `@unboxed` variant with a `Custom(string)` catch-all. Caveat: a
82+
> `\| string` may be a *genuine* escape hatch or merely *loose typing* (only the literals actually work
83+
> at runtime) — a runtime question the type-only tool can't answer, so the catch-all (a strict superset
84+
> of `string`) is the safe default; humans tighten to a closed enum when the runtime confirms it.
6085
6186
---
6287

@@ -98,7 +123,7 @@ Fixture: [`react-dom`](../test/golden/cases/react-dom)
98123
---
99124

100125
## Events & callbacks
101-
Fixture: [`events-callbacks`](../test/golden/cases/events-callbacks)
126+
Fixtures: [`events-callbacks`](../test/golden/cases/events-callbacks), [`overload-intersection`](../test/golden/cases/overload-intersection)
102127

103128
| TypeScript | ReScript |
104129
|---|---|
@@ -108,6 +133,8 @@ Fixture: [`events-callbacks`](../test/golden/cases/events-callbacks)
108133
| `KeyboardEventHandler<T>` (alias) | `ReactEvent.Keyboard.t => unit` |
109134
| `(value: string, index: number) => void` | `(string, int) => unit` |
110135
| `() => void \| Promise<void>` | `unit => 'a` — polymorphic return covers sync **and** async |
136+
| `(reason?: boolean \| string) => void` (**optional** param) | `option<boolOrString> => unit` — an optional param becomes `option<…>` (`None` = omitted), never a required arg. |
137+
| `((reason?: …) => void) & ((e: MouseEvent) => void)` (overload = intersection of call sigs, or a multi-call-signature interface) | an **opaque module with one zero-cost `%identity` accessor per signature**`module CloseToastFunc = { type t; external asReason: t => (option<boolOrString> => unit) = "%identity"; external asMouse: t => (ReactEvent.Mouse.t => unit) = "%identity" }`; the prop is typed `…CloseToastFunc.t` with an `` note. **No overload is dropped.** Falls back to 🔍 review only if a signature has an untypeable param. See [`overload-intersection`](../test/golden/cases/overload-intersection). |
111138

112139
Both forms work: an **inline** event param maps by the event's **name** (`MouseEvent``ReactEvent.Mouse.t`,
113140
`ChangeEvent``ReactEvent.Form.t`, `KeyboardEvent``ReactEvent.Keyboard.t`, …); a `*EventHandler<T>`
@@ -202,7 +229,7 @@ becomes `JSON.t`, `keyof T` becomes `string`, and nested records carry the type
202229
---
203230

204231
## Opaque-module unions
205-
Fixture: [`opaque-modules`](../test/golden/cases/opaque-modules), [`webapi`](../test/golden/cases/webapi)
232+
Fixtures: [`opaque-modules`](../test/golden/cases/opaque-modules), [`webapi`](../test/golden/cases/webapi), [`overload-intersection`](../test/golden/cases/overload-intersection)
206233

207234
When a union can't be an `@unboxed` variant — **multiple object shapes**, or **object | array<object>**
208235
(abstract members that `typeof`/`Array.isArray` can't split into a recognized variant shape) — it
@@ -222,14 +249,37 @@ module Boundary = { // Element | Element[]
222249
}
223250
```
224251

225-
The prop is typed `…Preset.t` with an `// ⓘ` note listing the constructors. This is the **only**
226-
sanctioned use of `%identity` — the value passes straight through (zero runtime cost).
252+
The prop is typed `…Preset.t` with an `// ⓘ` note listing the constructors. The value passes
253+
straight through (zero runtime cost).
254+
255+
### Reverse `as*` accessors — overloaded functions
256+
An **overloaded function** (≥2 call signatures — a TS intersection of call sigs `A & B`, or a
257+
multi-call-signature interface) has no native ReScript type. It becomes an opaque module with one
258+
zero-cost `%identity` **accessor** per signature (the reverse direction: opaque→concrete), so every
259+
overload stays callable. The consumer picks the view they need.
260+
261+
```rescript
262+
// `((reason?: boolean | string) => void) & ((e: MouseEvent) => void)`
263+
module CloseToastFunc = {
264+
type t
265+
external asReason: t => (option<boolOrString> => unit) = "%identity" // close, optional reason
266+
external asMouse: t => (ReactEvent.Mouse.t => unit) = "%identity" // use directly as onClick
267+
}
268+
```
269+
Usage: `CloseToastFunc.asReason(closeToast)(None)`; `onClick={CloseToastFunc.asMouse(closeToast)}`.
270+
Accessor names: `as` + the first param's name (`reason``asReason`), else the React-event type
271+
(`e: MouseEvent``asMouse`), else `asThunk` for a no-arg signature. Bucketed ✅ usable (nothing
272+
dropped); falls back to 🔍 review only if a signature has an untypeable param.
273+
274+
`from*` constructors and `as*` accessors are the **only** sanctioned uses of `%identity` — both are
275+
zero-cost (value-through).
227276

228277
| TypeScript | ReScript |
229278
|---|---|
230279
| `A \| B \| C` (≥2 object shapes) | `module … { fromA / fromB / fromC }` |
231280
| `Element \| Element[]` | `module … { fromElement / fromElements }` |
232281
| `File \| File[]` (with `--webapi`) | `module … { fromFile / fromFiles }` |
282+
| `((a?: T) => void) & ((e: MouseEvent) => void)` (overloaded fn) | `module … { asA: t => (option<T> => unit) / asMouse: t => (ReactEvent.Mouse.t => unit) }` |
233283

234284
---
235285

src/emit.mjs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,11 @@ function renderType(t, propName, cfg) {
150150
case 'array': return `array<${renderType(t.of, propName, cfg)}>`
151151
case 'dict': return `Dict.t<${renderType(t.of, propName, cfg)}>`
152152
case 'callback': {
153-
const render1 = (p) => (p.kind === 'event' ? p.res : renderType(p, propName, cfg))
153+
// an optional param `reason?: T` -> `option<T>` (None = the arg omitted)
154+
const render1 = (p) => {
155+
const base = p.kind === 'event' ? p.res : renderType(p, propName, cfg)
156+
return p.optional ? `option<${base}>` : base
157+
}
154158
const ps = !t.params || t.params.length === 0 ? 'unit' : t.params.map(render1).join(', ')
155159
const ret = renderType(t.ret || { kind: 'unit' }, propName, cfg)
156160
return (t.params && t.params.length > 1) ? `(${ps}) => ${ret}` : `${ps} => ${ret}`
@@ -355,6 +359,20 @@ function emitOrderedTypes(records, objUnboxed, opaque, idOf, depsOf, lines, cfg)
355359
* `from*` constructor per member. The prop is typed `Module.t`; the consumer builds a typed
356360
* value and `->fromX`-casts it (compiles to the raw value). */
357361
function renderOpaque(t, lines, cfg) {
362+
// Overloaded function: a module of zero-cost `%identity` ACCESSOR views (opaque -> concrete),
363+
// one per call signature — `external asReason: t => (option<…> => unit) = "%identity"`.
364+
if (t.variant === 'overload') {
365+
lines.push(`module ${t.name} = {`)
366+
lines.push(` type t`)
367+
const seen = new Set()
368+
for (const s of t.sigs) {
369+
if (seen.has(s.accessor)) continue
370+
seen.add(s.accessor)
371+
lines.push(` external ${s.accessor}: t => (${renderType(s.fn, '', cfg)}) = "%identity"`)
372+
}
373+
lines.push(`}`)
374+
return
375+
}
358376
const titleCase = (s) => s.charAt(0).toUpperCase() + s.slice(1)
359377
const pascalName = (s) => String(s).replace(/[^a-zA-Z0-9]+/g, ' ').trim().split(/\s+/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join('')
360378
const fromName = (m) => m.name

0 commit comments

Comments
 (0)