In February I shared osury, a CLI that turns an OpenAPI spec into ReScript types with Sury runtime schemas. It was early: a handful of features, and an invitation for feedback.
Since then osury has generated every API type of a production ReScript app — about 900 types, generated from the backend's spec rather than written by hand. A real API is a much stricter teacher than a fixture, and most of what follows was learned from it. This post walks through what osury does now, as of 2.11.
One spec in, types and schemas out
npm i -D osury
npx osury openapi.json src/Api.res
Types get @schema, so sury-ppx derives a runtime decoder, and @genType, so TypeScript sees them too. A discriminated union whose wire tag is not the case name comes out like this:
@genType
@tag("kind")
@schema
type pet = @as("cat") Cat({
name: string,
lives: int
}) | @as("dog") Dog({
name: string,
owner: @s.null Nullable.t<string>
})
Unions, the hard part
Most of the work went here, because backends spell unions in many ways.
- Tags follow the wire, not class names. The tag comes from
discriminator.mappingfirst, then from a_tagconst on the member, then from the schema name. Pydantic's style ofclass MetricGridBlocksending"_tag": "MetricGrid"just works. - Untagged unions when the arms differ by shape.
anyOf: [string, integer]has nothing to discriminate on, but the arms are different JSON types, so osury emits an unboxed variant that picks the arm by shape:
@genType
@tag("_tag")
@unboxed
@schema
type stringOrInt = String(string) | Int(int)
- Unions of literals become one enum, and
Literal[...] | strbecomes a plainstring— the literals add nothing a string does not already accept. Before 2.11 this case produced a variant that no real value could decode into. - A discriminator that does not discriminate is an error, reported with its JSON path, instead of code that compiles and fails at runtime.
Every endpoint, not just components
Request bodies, every 2xx response and path/query parameters become types, named after the operation:
type getV1WidgetsParams = {
limit: int,
cursor: option<string>,
order: option<order>
}
alongside postV1WidgetsRequest, getV1WidgetsResponse, postV1WidgetsResponse202 and so on. allOf is merged into a single record instead of dropping its $ref arms, inline enums are lifted into named poly variants with names derived from their fields, identical structures are deduplicated, and recursive types (including mutual recursion, A → B → A) come out as type rec a = … and b = ….
Null, absent and untyped
JSON null is a value, not a missing field, so anyOf: [T, null] becomes @s.null Nullable.t<T> — T | null in TypeScript — kept apart from option<T>. A schema with no type becomes JSON.t with @s.matches(S.json), so one loose field never costs the enclosing record its @schema.
A field that is neither required nor non-null has three wire states: a value, null, or no key at all. Decoding a response, that rarely matters. Encoding a PATCH body, it is the whole point. Under --exact-optional the type says it:
{carrier_id: None} // leave the field alone
{carrier_id: Some(Null)} // clear it
{carrier_id: Some(Value(id))} // set it
Validation, when you ask for it
OpenAPI validation keywords are always parsed and printed only under --refinements, because enforcing them changes what your app accepts. The field types stay the same; only the schemas gain checks:
type refined = {
id: @s.matches(S.uuid->S.to(S.string)) string,
slug: @s.with(S.minLength(_, 3)) @s.with(S.maxLength(_, 32)) @s.with(S.pattern(_, %re("/^[a-z-]+$/"))) string,
ratio: @s.with(S.gte(_, 0.)) @s.with(S.lte(_, 1.)) float,
count: @s.with(S.gt(_, 0)) @s.with(S.multipleOf(_, 5)) int
}
2.11 is built on the stable Sury 11, where a format such as S.uuid decodes to its own branded type; the ->S.to(S.string) keeps your field a plain string.
Errors that say where
Every error carries a JSON path, a typed kind and a hint, and the parser reports all of them instead of stopping at the first:
[#/Order/items] Missing required field "items" (hint: array type requires items schema)
The same spec, four languages
Under the hood osury is a small compiler: the spec is parsed once into a typed AST, transforms handle unions, deduplication and cycles, and an intermediate representation records every decision. ReScript is one printer of that IR; Effect TS, OCaml and Rust are three more.
| Target | Output |
|---|---|
| ReScript | records and variants with Sury schemas, genType for TypeScript |
| Effect TS | Effect Schema v4 |
| OCaml | plain types with yojson codecs, no ppx |
| Rust | serde structs and enums |
Everything above works in all four. The exception is part of --refinements: Rust and OCaml do not check pattern or string formats, and Effect TS checks only uuid among the formats — each such case is reported as a warning, never dropped silently. The --target flag picks the language.
How it is checked
For a while every target had a golden test: a spec covering every construct, with the output compared byte for byte. All green — yet only the ReScript output was ever compiled, and some of the other outputs did not build. A snapshot only tells you the output changed, not that it is right.
So every target, under every flag combination, now has to build with its own compiler (rescript with sury-ppx, ocamlfind, cargo, tsc --strict) and decode sample data generated from the same spec. For 2.11 I ran that check against the two production specs as well. It found three bugs no fixture had — the Literal[...] | str case above, a Rust enum variant named 3pl, and samples missing a _tag the API always sends. All fixed; both specs now pass in every target.
Try it
The fastest way is the playground: paste your spec and see all four targets, generated in the browser by the same code the CLI runs.
npm i -D osury
npx osury openapi.json src/Api.res
npx osury openapi.json --refinements --exact-optional
npx osury openapi.json --target rust -o src/api.rs
Source on GitHub, package on npm, MIT licensed. Issues and specs that break it are very welcome. Thanks to Dmitry Zakharov for Sury, which all of this is built on.