Generate ReScript types with Sury schemas from OpenAPI specifications.
Huge thanks to the ReScript team for an amazing language, and special thanks to @DZakh for the incredible Sury library that made this project possible.
Now at v1.0.0. The codegen is in production use against real-world OpenAPI specs (both Pydantic/FastAPI and Django/DRF generated). The pipeline is feature-complete for the OpenAPI 3.x patterns most code-first generators emit; edge cases beyond that are added on demand. Issues and PRs welcome.
- OpenAPI 3.x → ReScript types with full discriminated-union support
@schemaannotations for Sury PPX runtime validation@genTypefor TypeScript interop with type-safe literal unions- Discriminated unions via
discriminator.mapping(OpenAPI-standard, primary) with fallback to_tag.const(Effect-style convention) - Custom discriminator property names via
discriminator.propertyName(@tag("type"),@tag("kind"), etc., not just_tag) - Inline enum auto-promotion to named top-level types with field-based naming and structural deduplication
- Path operation types — both
Responsetypes fromresponses[200]andParamstypes from query/pathparameters[] - JSON
nullsupport viaNullable.t<T>(maps toT | nullin TypeScript, distinct fromoption<T>forundefined) - Untyped/Unknown handling via
JSON.twith@s.matches(S.json)so untyped fields don't poison@schemapropagation through enclosing types - Automatic deduplication of identical union/enum structures
- TypeScript shims generated alongside (
Dict.gen.ts,JSON.gen.ts,Nullable.res,Nullable.shim.ts)
npm install -D osury# Generate to default ./Generated.res + shims
npx osury openapi.json
# Generate to specific directory
npx osury openapi.json src/API.res
# Creates: src/API.res, src/Dict.gen.ts, src/JSON.gen.ts,
# src/Nullable.res, src/Nullable.shim.ts
# With explicit output flag
npx osury generate openapi.json -o src/Schema.res
# Show help
npx osury --helpOpenAPI Input:
{
"components": {
"schemas": {
"User": {
"type": "object",
"properties": {
"id": { "type": "integer" },
"email": { "type": "string" },
"status": { "$ref": "#/components/schemas/Status" },
"role": {
"anyOf": [
{ "$ref": "#/components/schemas/Admin" },
{ "$ref": "#/components/schemas/Guest" }
]
}
},
"required": ["id", "email"]
},
"Status": {
"type": "string",
"enum": ["pending", "active", "blocked"]
},
"Admin": {
"type": "object",
"properties": {
"_tag": { "type": "string", "const": "Admin" },
"permissions": { "type": "array", "items": { "type": "string" } }
}
},
"Guest": {
"type": "object",
"properties": {
"_tag": { "type": "string", "const": "Guest" },
"expiresAt": {
"anyOf": [{ "type": "string" }, { "type": "null" }]
}
}
}
}
}
}Generated ReScript (Schema.res):
module S = Sury
@genType
@schema
type status = [#pending | #active | #blocked]
@genType
@schema
type admin = {
permissions: option<array<string>>
}
@genType
@schema
type guest = {
expiresAt: @s.null Nullable.t<string>
}
@genType
@tag("_tag")
@schema
type adminOrGuest = Admin({
permissions: option<array<string>>
}) | Guest({
expiresAt: @s.null Nullable.t<string>
})
@genType
@schema
type user = {
id: int,
email: string,
status: option<status>,
role: option<adminOrGuest>
}Generated TypeScript (Schema.gen.ts via genType):
import type {t as Nullable_t} from './Nullable.gen';
export type status = "pending" | "active" | "blocked";
export type admin = { readonly permissions: (undefined | string[]) };
export type guest = { readonly expiresAt: Nullable_t<string> };
export type adminOrGuest =
{ _tag: "Admin"; readonly permissions: (undefined | string[]) }
| { _tag: "Guest"; readonly expiresAt: Nullable_t<string> };
export type user = {
readonly id: number;
readonly email: string;
readonly status: (undefined | status);
readonly role: (undefined | adminOrGuest)
};The library uses sury-ppx for code-first approach — @schema annotation automatically generates runtime validators from type definitions.
Try it online: osury-production.up.railway.app
This repository also includes a local demo for development:
npm run demoOpen http://localhost:4173/demo/.
- Upload OpenAPI JSON as a file
- Paste OpenAPI JSON into a text area
- Formatted ReScript output
- Formatted TypeScript output (derived from osury AST and matching generated ReScript structures)
Generated alongside the main Schema.res:
Dict.gen.ts — TypeScript shim for dictionaries:
export type t<T> = { [key: string]: T };JSON.gen.ts — TypeScript shim for untyped/Unknown fields:
export type t = unknown;Nullable.res — ReScript nullable type (option<T> with T | null TS mapping):
@genType.import(("./Nullable.shim.ts", "t"))
type t<'a> = option<'a>Nullable.shim.ts — TypeScript shim for nullable:
export type t<T> = T | null;| Annotation | Purpose |
|---|---|
@genType |
TypeScript type generation |
@schema |
Sury PPX validation schema |
@tag("_tag") |
Discriminated union tag — default Effect TS convention; overridable via discriminator.propertyName (e.g. @tag("type")) |
@s.null |
Field-level JSON null support (for Nullable.t<T> fields) |
@s.matches(S.json) |
Per-field synthesizer for JSON.t so untyped fields don't poison enclosing @schema |
@unboxed |
Primitive-only union optimization |
@as("name") |
Reserved keyword field mapping |
For the generated code to compile, your project needs:
- rescript >= 12.0
- sury >= 11.0 (for
@schema) - sury-ppx >= 11.0 (for
@schemaPPX) - gentype (for
@genType)
| OpenAPI | ReScript |
|---|---|
string |
string |
number |
float |
integer |
int |
boolean |
bool |
null |
unit |
array |
array<T> |
object |
record { field: T } |
$ref |
type reference |
enum (inline) |
extracted to named type sortDirection = [#asc | #desc] |
enum (top-level) |
poly variant [#A | #B] |
const (single string) |
one-element enum (used for discriminator tags) |
schema with no type |
JSON.t (TS: unknown) with @s.matches(S.json) |
anyOf: [T, null] |
Nullable.t<T> (TS: T | null) |
anyOf/oneOf of string literals (enum/const, incl. behind $ref) |
merged into one named poly variant [#a | #b | #all] |
anyOf: [A, B, ...] (no discriminator) |
extracted variant type with structural name |
oneOf + discriminator |
poly variant with tags from discriminator.mapping |
allOf |
merged object type |
additionalProperties |
Dict.t<T> |
default value |
ignored for requiredness — optionality follows required[] only |
parameters[] (query + path) |
synthetic <method><Path>Params record |
responses[200].schema |
<method><Path>Response type |
osury resolves variant case tags through a three-level priority chain, so the ReScript-side tag always matches the wire-format truth, never the class name on the backend:
discriminator.mapping— OpenAPI 3.x standard, primary source. Works with any property name (_tag,tag,type,kind, …)._tag.const— fallback when no explicit mapping is declared (Effect-style implicit convention).- Ref name — last-resort default for
$refitems with no const information.
The practical effect: class names on the backend can diverge from wire-format
discriminator values without breaking osury. Pydantic's natural style
(class MetricGridBlock ↔ _tag: "MetricGrid") works out of the box as long as
the discriminator mapping is declared in the schema.
Block:
oneOf:
- { $ref: "#/components/schemas/MetricGridBlock" }
- { $ref: "#/components/schemas/ProseBlock" }
discriminator:
propertyName: _tag
mapping:
MetricGrid: "#/components/schemas/MetricGridBlock"
Prose: "#/components/schemas/ProseBlock"Generates:
@genType @tag("_tag") @schema
type block = MetricGrid({
metrics: array<string>
}) | Prose({
text: string
})Note the case names (MetricGrid, Prose) come from the mapping keys, not
from the schema class names (MetricGridBlock, ProseBlock). Case payloads
are inlined records — fields are copied from the referenced schema and the
discriminator property is filtered out to avoid duplication with @tag.
Types are generated from both responses and parameters of each path operation.
{
"paths": {
"/users": {
"get": {
"responses": {
"200": {
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/UserList" }
}
}
}
}
}
}
}
}Generates: type getUsersResponse = userList
Query and path parameters are folded into a synthetic object schema and pushed
through the same parsing pipeline (so all rules — default → required,
anyOf [T, null] → Nullable.t<T>, inline enum → named type — apply uniformly).
{
"paths": {
"/products": {
"get": {
"parameters": [
{ "in": "query", "name": "sort_field",
"schema": { "type": "string", "enum": ["sales", "clicks", "impressions"] } },
{ "in": "query", "name": "limit",
"schema": { "type": "integer", "default": 50 } }
]
}
}
}
}Generates:
@genType @schema
type sortField = [#sales | #clicks | #impressions] // promoted from inline enum
@genType @schema
type getProductsParams = {
sort_field: option<sortField>,
limit: int, // has default → required
}Headers and serialization details (style/explode) are intentionally excluded —
they belong to the HTTP client layer, not the schema contract.
MIT