Turn any typed React/TypeScript package into idiomatic, type-safe ReScript bindings. It reads
.d.tsthrough the TypeScript compiler API and emits@react.componentbindings β deterministically, with no AI, no%identity, no unsafe casts.
Point it at anything typed β a published npm package (any version), a local folder, or a single
.d.ts β and get compile-ready ReScript 12 bindings you'd otherwise hand-write and hand-maintain:
npx @juspay/rescript-bindgen --pkg @radix-ui/react-dialog --out generated --reportScope today: it targets React component packages β it binds
FC/forwardRef/ function-component exports. A non-React TypeScript library (e.g. a backend lib) printsNo React components found to generate. Generating bindings for any TypeScript surface β plain functions, classes, type aliases β is the direction we're actively building toward.
- π Works on real libraries. Tested across 50+ of the most popular React packages β MUI, Radix UI, Headless UI, Ariakit, react-day-picker, cmdk, vaul, β¦ β where ~93% of components bind cleanly.
- π Type-safe & zero-cost. Enums β
@asvariants, multi-type props β@unboxeduntagged variants, refs/events/CSS β their exact ReScript types. The raw runtime value reaches JS untouched. - π― Deterministic. Same input β same output, every run. No model, no guessing.
- π© Honest. Anything it can't bind type-safely is flagged for review, never silently faked.
- π¦ Any source. npm package Β· local folder Β· single
.d.tsΒ·pkg.pr.newpreview URL.
Hand-writing bindings for a real component library means hundreds of props across dozens of
components β tedious, error-prone, and stale the moment the library updates. The only other tool in
this space, ts2ocaml, can't generate React component
bindings (it emits external x: any with a FIXME for ForwardRefExoticComponent).
rescript-bindgen is purpose-built for React component packages: it drives the TypeScript
type-checker to resolve Omit<β¦>, intersections, imported enums, RefAttributes, generics, and
indexed-access types, then emits idiomatic ReScript 12 β and you re-run it on every upstream bump.
Anything it can't bind in a fully type-safe way is flagged for human review, never silently hacked.
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("@acme/ui") @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.
Run against 50+ of the most-used React / TypeScript packages β ~9,700 of 10,400 components (93%) bind type-safely, with no unsafe casts. A sample:
| Library | Components | Bound clean |
|---|---|---|
Radix UI (react-dialog, react-dropdown-menu, react-select, themes, β¦) |
170+ | 100% |
@ariakit/react |
138 | 130 |
@headlessui/react |
63 | 59 |
@mui/material |
133 | 86 |
react-aria-components |
246 | 71 |
react-day-picker Β· cmdk Β· vaul |
27 Β· 10 Β· 6 | 100% |
sonner Β· react-hot-toast Β· react-toastify |
1 Β· 3 Β· 6 | 100% |
formik Β· @hello-pangea/dnd Β· react-window |
4 Β· 3 Β· 2 | 100% |
lucide-react Β· @phosphor-icons/react |
5,876 Β· 3,044 | 100% (icons) |
Generic-heavy chart libraries (recharts, victory, chart.js) and class/CJS-only components are the long tail β and whatever can't be bound type-safely is flagged for review, never faked.
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.
Every PR and push to main auto-publishes a commit-pinned preview via
pkg.pr.new β so you can try a fix before it's released. Install the exact build
by SHA (the URL is also posted as a comment on each PR):
npm i -D https://pkg.pr.new/@juspay/rescript-bindgen@<sha>
npx @juspay/rescript-bindgen --pkg <some-package> --out generated --reportPreviews live on pkg.pr.new (not npm), are ephemeral, and never affect the latest/beta you get
from a normal npm install.
The package spec is the exact one you'd give to npm install (name, name@1.2.3,
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 @juspay/rescript-bindgen --pkg react-day-picker --out generated
npx @juspay/rescript-bindgen --pkg @mui/material@7.0.0 --only Button --out generated
# a single .d.ts file, printed to stdout
npx @juspay/rescript-bindgen --file ./types/Foo.d.ts --stdout
# a local folder containing an index.d.ts
npx @juspay/rescript-bindgen --dir ./node_modules/some-lib --out generated| Flag | Meaning |
|---|---|
--pkg <name[@ver]> |
npm package (auto-installed to a scratch cache if absent). A bare name resolves the latest dist-tag, so prerelease-only packages work |
--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) |
--webapi |
emit Webapi.* types for File / FileList / FormData |
--clean |
remove stale generated files in --out before writing |
--no-install |
don't auto-install a missing --pkg |
Untyped JS packages produce only loose skeleton bindings β the tool is type-driven.
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 @juspay/rescript-bindgen --pkg @mui/material --out generated --reportINPUT 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.
| 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 |
ComponentType<P> / FC<P> |
React.component<p> |
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 |
JSON.t (opaque value you build/decode) |
object | object[], multi-object union |
opaque-type module (zero-cost from* constructors) |
any |
flagged as defect β never silently typed |
| undiscriminable union (object shapes) | flagged for human review |
Full mapping reference:
docs/TYPE_MAPPING.mdβ every case, each backed by a golden fixture.
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.
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 @juspay/rescript-bindgen --pkg @mui/material --out generated --reportimport { 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.
npm test # smoke test + golden snapshot suite
npm run test:compile # compile every golden fixture on ReScript (asserts 0 warnings)
npm run gen -- --pkg <some-package> --out generated --reportGolden fixtures live in test/golden/cases/ (self-contained .d.ts β expected .res); the
ReScript compile sandbox in test/sandbox/ compile-checks generated output. The mapping contract
is documented in docs/TYPE_MAPPING.md.
MIT Β© Juspay Technologies