Skip to content

Fix #186: every arm of a views module gets both doors, not just the nameable ones - #187

Merged
jagguji merged 1 commit into
mainfrom
fix/186-views-module-readers
Aug 7, 2026
Merged

Fix #186: every arm of a views module gets both doors, not just the nameable ones#187
jagguji merged 1 commit into
mainfrom
fix/186-views-module-readers

Conversation

@jagguji

@jagguji jagguji commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Closes #186.

The bug in one line

A views module emits from<X> (put a value in) and, since #122, as<X> (read one back out). The constructor name falls back to the arm's kind when there's no name hint; the reader gate had no fallback. Two sources for one decision, so they disagreed:

// src/emit.mjs:914 — constructor: hint, ELSE fall back to the kind
const fromName = (m) => m.name ? 'from' + pascalName(m.name)
    : 'from' + titleCase( m.type.res || m.type.kind || 'value')   // fromArray, fromNumber

// src/emit.mjs:969 (before) — reader: structured kind OR hint, no fallback
if (m.type.kind === 'typeRef' ||  || m.name) {  }

An arm with a constructor but no derivable hint got a one-way door.

Why it's a bug and not a policy

The split tracked naming, not soundness, and it produced distinctions nobody would defend:

arm reader before? why
string[] no primitive element — nothing to build a hint from
string[][] yes TS names the inner element Array, so a hint existed
number no no hint
boolean yes opaqueUnion hard-codes name: 'Bool'

string[] having no reader while string[][] has one is the give-away. Nothing about safety separates them: asArray: t => array<string> is exactly as unchecked as the asNamedA next to it, and both require the caller to establish the arm first — which is this module's contract either way.

The consumer cost this closes

Highcharts, real and in the baselines:

export type ColorString = string;
export type ColorType = (ColorString | GradientColorObject | PatternObject);

ColorType.t is returned by the library — HighchartsSharedTypes.res:2550, get: option<string> => ColorType.t, i.e. Highcharts.color("#ff0000").get() — and what comes back is almost always the plain string. Before:

let c = someColor.get(None)          // holds "#ff0000"
c->ColorType.asGradientColorObject   // compiles. WRONG — reads a string as a record
c->ColorType.asString                // ✗ didn't exist

There was no way to get the string back out, and the only thing that compiled was a reader for the wrong shape — silently, since these are zero-cost %identity views with no runtime check. ColorType.t appears 1085 times in blend's bindings.

The fix

Make the reader gate mirror fromName rather than re-derive the arm's identity — every arm reaching the generic from* branch gets both doors. This deletes a special case instead of adding one. Arms that legitimately need no reader (tagSet, literal, none) each are their own runtime value and already continue above the gate.

 module ColorType = {
   type t
   external fromString: string => t = "%identity"
+  external asString: t => (string) = "%identity"
   external fromGradientColorObject: gradientColorObject => t = "%identity"
   external asGradientColorObject: t => (gradientColorObject) = "%identity"

 module ChartsSonificationSpeechMappingOptionsPitch = {
   external fromString: string => t = "%identity"
+  external asString: t => (string) = "%identity"
   external fromNumber: float => t = "%identity"
+  external asNumber: t => (float) = "%identity"

One deliberate exclusion, now principled where the old one was incidental: a type-variable arm keeps fromTypeVar and gets no reader, because as…: t => 'a unifies with any type at the call site — a universal unsafe cast, not a view of one arm. base-ui's RootFilteredItems (readonly any[] | readonly Group<any>[]) is the real instance, and the fixture reproduces it.

Measured, then reconciled exactly

53 of 1624 views modules across the baselines had a reader-less arm; 42 were the string | number | record shape. Counting individual missing readers predicted 96; the fix adds 95, and the 1 remainder is the excluded typeVar arm.

Baseline diff: 95 insertions, 0 deletions or modifications, 6 files. No existing declaration changes, so — unlike #185no rename churn. No metrics.json moved: this adds capability without reclassifying anything.

Teeth, verified in both directions

Broke it two ways to confirm the fixture actually fails:

  • revert to the old name-hint gate → 2 cases fail (views-module-readers, record-props)
  • drop the typeVar exclusion → 1 case fails (views-module-readers)

The fixture carries the string[] vs string[][] pair on purpose, so a future change re-coupling the reader to the name hint makes them diverge again and fails. It also pins the typeVar exclusion in a golden, not only in the benchmark — the benchmark is an opt-in gate that a wholesale "emit readers unconditionally" simplification would sail past.

Verification

  • npm test — smoke + 113 goldens match
  • npm run test:compile — 113/113 compile on ReScript
  • npm run bench — 10/10 identical to (updated) baselines
  • docs/TYPE_MAPPING.md — "Opaque-module unions" section updated; the old text said "every structured arm"

🤖 Generated with Claude Code

…ameable ones

A views module is `from<X>` (put a value in) plus `as<X>` (read one back out, #122). The
reader was emitted only for a structured KIND (record/callback/tuple/named) or an arm with
an explicit name hint — while the CONSTRUCTOR name falls back to the arm's kind. Two
different sources for the same decision, so they disagreed, and an arm could get a one-way
door: `fromArray: array<string> => t` with no `asArray`.

That split tracked NAMING, not soundness. The tell is that it produced distinctions nobody
would defend:

  · `string[]`   -> NO reader
  · `string[][]` -> reader, purely because TS names the inner element `Array` so a hint existed
  · `number`     -> NO reader
  · `boolean`    -> reader, only because `opaqueUnion` hard-codes `name: 'Bool'`

The fix makes the gate MIRROR `fromName` instead of re-deriving the arm's identity: every arm
reaching the generic `from*` branch gets both doors. This DELETES a special case rather than
adding one. The arms that legitimately need no reader (`tagSet`, literal, `none`) each ARE
their own runtime value and already `continue` above the gate.

CONSUMER COST THIS CLOSES. Highcharts `ColorType = ColorString | GradientColorObject |
PatternObject` is RETURNED by `color(…).get()` (`HighchartsSharedTypes.res:2550`,
`get: option<string> => ColorType.t`) and almost always holds the plain color string. With no
`asString`, the only reader that compiled was `asGradientColorObject` — reading a string as a
record, silently, since these views are unchecked. `ColorType.t` appears 1085 times in blend's
bindings. `asString` is exactly as safe as the `asGradientColorObject` beside it: both are
zero-cost views whose arm the caller must establish first, which is this module's contract
either way.

ONE DELIBERATE EXCLUSION, and it is now principled where the old one was incidental: a
TYPE-VARIABLE arm keeps `fromTypeVar` with no reader, because `as…: t => 'a` unifies with ANY
type at the call site — a universal unsafe cast, not a view of one arm. base-ui's
`RootFilteredItems` is the real instance.

MEASURED, then reconciled exactly. Across the baselines, 53 of 1624 views modules had a
reader-less arm (42 of them the `string | number | record` shape). Counting the individual
missing readers predicted 96; the fix adds 95, and the 1 remainder is the deliberately
excluded typeVar arm. The baseline diff is 95 insertions and ZERO deletions or modifications
across 6 files — no existing declaration changes, so unlike #185 there is no rename churn.
No metrics.json moved: this adds capability without reclassifying anything.

FIXTURE HAS TEETH IN BOTH DIRECTIONS, verified by breaking it two ways:
  · revert to the old name-hint gate  -> 2 cases fail (views-module-readers, record-props)
  · drop the typeVar exclusion        -> 1 case fails (views-module-readers)
The fixture carries the `string[]` vs `string[][]` pair specifically so that a future change
re-coupling the reader to the name hint makes them diverge again and fails. It also pins the
typeVar exclusion in a GOLDEN rather than only in the benchmark, since the benchmark is an
opt-in gate that a wholesale "emit readers unconditionally" simplification would sail past.

npm test passes, 113 goldens match and compile, benchmark 10/10 identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jagguji
jagguji deployed to benchmark August 7, 2026 14:43 — with GitHub Actions Active
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

Benchmark: ✅ PASS

Package Compile Diff vs baseline usable review broken Verdict
@juspay/blend-design-system@0.0.36 identical 102 5 0 ✅ PASS
@juspay/blend-design-system@0.0.37-beta.8 identical 215 7 0 ✅ PASS
@juspay/blend-design-system@0.0.37 identical 219 7 0 ✅ PASS
react-day-picker@10.0.1 identical 19 7 0 ✅ PASS
react-tooltip@6.0.7 identical 1 0 0 ✅ PASS
react-markdown@10.1.0 identical 0 2 0 ✅ PASS
@smastrom/react-rating@1.5.0 identical 1 0 0 ✅ PASS
clsx@2.1.1 identical 0 0 0 ✅ PASS
hono@4.12.25 identical 0 0 0 ✅ PASS
@base-ui-components/react@1.0.0-rc.0 identical 174 21 0 ✅ PASS

@pkg-pr-new

pkg-pr-new Bot commented Aug 7, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@juspay/rescript-bindgen@187

commit: a5b89a4

@jagguji
jagguji merged commit 2d74702 into main Aug 7, 2026
11 of 12 checks passed
@jagguji
jagguji deleted the fix/186-views-module-readers branch August 7, 2026 15:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant