osury
v2.3.0
Published
Generate ReScript types with Sury schemas from OpenAPI specifications
Maintainers
Readme
osury
Generate ReScript types with Sury schemas from OpenAPI specifications.
Acknowledgements
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.
Project Status
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.
Features
- 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)
Installation
npm install -D osuryUsage
CLI
# 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 --helpFull Example: OpenAPI → ReScript → TypeScript
OpenAPI 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.
Demo Playground
Try it online: osury-production.up.railway.app
This repository also includes a local demo for development:
Run locally
npm run demoOpen http://localhost:4173/demo/.
What it supports
- 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)
Helper Files
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;Generated Annotations
| 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 |
Requirements
For the generated code to compile, your project needs:
Type Mapping
| 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: [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 | field becomes required |
| parameters[] (query + path) | synthetic <method><Path>Params record |
| responses[200].schema | <method><Path>Response type |
Discriminated Unions
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.
Path Types
Types are generated from both responses and parameters of each path operation.
Response types
{
"paths": {
"/users": {
"get": {
"responses": {
"200": {
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/UserList" }
}
}
}
}
}
}
}
}Generates: type getUsersResponse = userList
Params types
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.
License
MIT
