@juspay/rescript-bindgen
v1.0.0
Published
Deterministic TypeScript -> ReScript binding generator. Reads .d.ts via the TypeScript compiler API and emits type-safe @react.component bindings — no AI, no %identity.
Downloads
0
Readme
@juspay/rescript-bindgen
Deterministic TypeScript → ReScript binding generator. Reads
.d.tsfiles through the TypeScript compiler API and emits type-safe@react.componentbindings — no AI, no%identity, no unsafe casts.
Built to keep @juspay/rescript-blend
in sync with @juspay/blend-design-system
without hand-maintaining bindings — but it works on any typed React component package.
Why
There is one existing tool in this space, ts2ocaml,
but it cannot generate React component bindings (it emits external x: any with a
FIXME for ForwardRefExoticComponent) and doesn't match a project's house style.
rescript-bindgen is purpose-built for React component packages: it resolves
Omit<…>, intersections, imported enums, RefAttributes, and indexed-access types
via the TypeScript type-checker, then emits idiomatic ReScript 12 bindings.
The generator is deterministic: identical input always produces identical output. Anything it cannot bind in a fully type-safe way is flagged for human review, never silently hacked.
What it produces
Given this .d.ts:
declare const Button: import('react').ForwardRefExoticComponent<{
buttonType?: ButtonType; // enum
text?: string;
width?: string | number; // multi-type
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "style" | "className">>;
export default Button;it emits:
type buttonType =
| @as("primary") Primary
| @as("secondary") Secondary
@unboxed type widthValue = Str(string) | Num(float)
@module("@juspay/blend-design-system") @react.component
external make: (
~buttonType: buttonType=?,
~text: string=?,
~width: widthValue=?,
~onClick: ReactEvent.Mouse.t => unit=?,
~id: string=?,
@as("aria-label") ~ariaLabel: string=?,
) => React.element = "Button"<Button width=Num(5.0) /> sends width: 5 to JS; <Button width=Str("100%") />
sends width: "100%". Type-safe and zero-cost — the @unboxed variant is erased
at runtime.
Install
npm install -D @juspay/rescript-bindgen
# or run ad-hoc:
npx @juspay/rescript-bindgen --helpRequires Node ≥ 20. ReScript 12 is recommended for the generated output.
Usage
Generate bindings for any typed React package
The package spec is the exact one you'd give to npm install (name, [email protected],
a beta, or a pkg.pr.new URL). It's installed into a scratch cache and read from there,
so output is reproducible and version-pinned.
# a published package (any version)
npx rescript-bindgen --pkg react-day-picker --out generated
npx rescript-bindgen --pkg @mui/[email protected] --only Button --out generated
# a single .d.ts file, printed to stdout
npx rescript-bindgen --file ./types/Foo.d.ts --stdout
# a local folder containing an index.d.ts
npx rescript-bindgen --dir ./node_modules/some-lib --out generated| Flag | Meaning |
|------|---------|
| --pkg <name[@ver]> | npm package (auto-installed to a scratch cache if absent) |
| --file <path.d.ts> | a single declaration file (one component) |
| --dir <folder> | a folder containing index.d.ts |
| --out <dir> | output directory (default generated) |
| --only <Comp> | generate just one component |
| --report | also write _REPORT.md — the ready / loose / review / defect summary |
| --from <name> | override the @module(...) import name |
| --stdout | print to stdout instead of writing files (single component) |
| --no-install | don't auto-install a missing --pkg |
Untyped JS packages produce only loose skeleton bindings — the tool is type-driven.
Get the report
Add --report to also emit _REPORT.md next to the bindings — a checklist of which
components are ready, which props were widened to string (loose), which need human
review, and which are broken (unknown/any):
npx rescript-bindgen --pkg @mui/material --out generated --reportHow it works
INPUT RESOLVE EXTRACT MAP EMIT REPORT
.d.ts / pkg → locate types → TS type-checker → mapping table → ReScript 12 → _REPORT.md
→ IR (fixed table) emitter (--report)- Resolve (
resolve.mjs) — find the declaration entry for a file / dir / npm package. - Extract (
extract.mjs) — the TypeScript type-checker resolvesOmit, intersections,RefAttributes, generics and indexed-access into a flat prop list (the IR). - Map (
extract.mjs+emit.mjs) — each TS type maps to ReScript via a fixed table (below). - Emit (
emit.mjs) — render the IR to ReScript 12:@asvariants,@unboxedvariants, records, and the@module @react.component external makebinding. - Report (
report.mjs) — with--report, write a per-component_REPORT.mdbucketing props into ready / loose / review / defect.
Type mapping
| TypeScript | ReScript |
|---|---|
| string / boolean | string / bool |
| number | int (count/size/index names) or float |
| string-literal union / enum | @as variant |
| string \| number, string \| string[] | @unboxed untagged variant (Str \| Num \| StrArr) |
| ReactNode / ReactElement | React.element |
| React.CSSProperties | JsxDOM.style |
| MouseEvent / FocusEvent / ChangeEvent / KeyboardEvent | ReactEvent.Mouse.t / .Focus.t / .Form.t / .Keyboard.t |
| Ref<HTMLX> | React.ref<Nullable.t<Dom.element>> |
| X[] / Record<K,V> | array<X> / Dict.t<V> |
| Date / CSSObject['x'] | Date.t / string |
| Omit / Pick / Partial / intersection | resolved & flattened by the checker |
| unknown / any | flagged as defect — never typed |
| undiscriminable union (object shapes) | flagged for human review |
Multi-type props use untagged variants
For string | number style props the tool emits a ReScript 11+
untagged variant — the
officially recommended, zero-cost way to bind a JS value that can be several types.
The raw value reaches JS, with no %identity, @unwrap, or Obj.magic.
When a union's members can't be told apart at runtime (e.g. two object shapes), the
tool refuses to guess: it emits a string placeholder with an inline
// ⚠️ REVIEW comment and lists it in the report.
The report
Add --report to write _REPORT.md next to the bindings — a checklist of components:
[x]ready to use — every prop bound type-safely[~]needs human review — a multi-type prop couldn't be auto-discriminated[ ]broken — hasunknown/anyprops that won't work as typed (fix upstream)(n loose)— props widened tostring(compile and work, just loosely typed)
This separates what won't work (defects) from what needs a decision (review) from what's done (ready). Each flagged prop is listed with its original TypeScript.
npx rescript-bindgen --pkg @mui/material --out generated --reportLibrary API
import { extractComponent, extractModule, emit, report } from '@juspay/rescript-bindgen'
const ir = extractComponent('node_modules/pkg/dist/Button.d.ts', { from: 'pkg' })
const code = emit(ir) // ReScript source string
const { defects, review, loose } = report(ir)Exports: extractComponent, extractModule, emit, report, resolveInput,
writeReport. Full type definitions ship in types.d.ts.
Limitations
- Generic components (
<T extends …>) — generic type parameters resolve tounknownand are flagged as defects (e.g. blend'sDataTable). Needs concrete types upstream or generic-binding support (planned). - Sub-components (
Drawer.Title) — detected in the IR; nested-module emission is planned. - Untyped JS — produces loose skeleton bindings only (no types to read).
intvsfloatfornumberis a name heuristic — verify numeric props if exact.
Development
npm test # self-contained smoke test
npm run gen -- --pkg <some-package> --out generated --report
node test/ts-demo.mjs # live TypeScript compiler-API walkthrough (see test/DEMOS.md)The ReScript compile sandbox lives in test/sandbox/ (used to compile-check
generated output during development).
License
MIT © Juspay Technologies
