Blog Post

Building osury: Bridging the Gap Between OpenAPI, Type-Safe ReScript, and TypeScript

By alex
February 08, 2026
api Compiler contract openapi ReScript Typescript
Building osury: Bridging the Gap Between OpenAPI, Type-Safe ReScript, and TypeScript

When you build a frontend in ReScript that consumes a REST API described by an OpenAPI specification, you face a fundamental problem: how do you keep your types in sync with the backend? You can write them by hand, but that is tedious, error-prone, and silently breaks whenever the API changes. You can use an existing codegen tool, but none of them target ReScript — and even fewer generate runtime validation schemas alongside static types.

osury is a code generator that transforms OpenAPI 3.x specifications into ReScript types annotated with Sury schemas, @genType for TypeScript interop, and @tag("_tag") for Effect TS-compatible discriminated unions. One command gives you compile-time types, runtime validators, and TypeScript definitions — all derived from a single source of truth.

The Problem

Consider a typical full-stack workflow. Your backend team publishes an OpenAPI spec. Your frontend is written in ReScript because you value type safety and sound compilation. Between these two worlds lies a manual, fragile translation layer.

Here is what that layer looks like in practice. Your API defines a User:

{
  "User": {
    "type": "object",
    "properties": {
      "id": { "type": "integer" },
      "email": { "type": "string" },
      "name": { "anyOf": [{ "type": "string" }, { "type": "null" }] },
      "status": { "$ref": "#/components/schemas/Status" }
    },
    "required": ["id", "email"]
  },
  "Status": {
    "type": "string",
    "enum": ["pending", "active", "blocked"]
  }
}

Now you sit down and write the ReScript types by hand:

type status = [#pending | #active | #blocked]

type user = {
  id: int,
  email: string,
  name: option<string>,
  status: option<status>,
}

This works, but you have introduced three separate problems.

Problem 1: No runtime validation. The ReScript type tells the compiler what shape data should have, but it says nothing about what the server actually sends. If the backend returns "id": "not-a-number", your program will compile fine and crash at runtime.

Problem 2: Manual synchronization. When the backend adds a field, renames one, or changes name from nullable to required, nothing alerts you. Your hand-written types silently drift from reality.

Problem 3: The null/undefined gap. OpenAPI distinguishes between "absent" (the field is not in the JSON) and "null" (the field is present with value null). ReScript's option<T> maps to undefined in JavaScript, not null. If your API sends { "name": null }, you need a different type — but which one?

These problems compound. In a real-world API with dozens of schemas, discriminated unions, nested references, and nullable fields, maintaining types by hand becomes a full-time job that no one wants.

The Solution

osury automates this entire layer. Given an OpenAPI JSON file, it produces:

  • ReScript types with correct nullability semantics
  • @schema annotations that generate Sury runtime validators via PPX
  • @genType annotations for automatic TypeScript type generation
  • @tag("_tag") for discriminated unions compatible with Effect TS
  • Proper topological ordering so types compile without forward references
  • Helper modules for Nullable.t<T> (maps to T | null in TypeScript) and Dict.t<T>

You run one command and get everything:

npx osury openapi.json src/Schema.res

This creates four files: Schema.res with all your types, Dict.gen.ts, Nullable.res, and Nullable.shim.ts.

Example 1: A Real API with Unions and Nullable Fields

Let's walk through a complete example. Here is an OpenAPI specification for a simple API with users, roles (as a discriminated union), and nullable fields:

{
  "openapi": "3.0.0",
  "info": { "title": "Example API", "version": "1.0.0" },
  "paths": {
    "/users": {
      "get": {
        "operationId": "getUsers",
        "responses": {
          "200": {
            "description": "List of users",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": { "$ref": "#/components/schemas/User" }
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "User": {
        "type": "object",
        "properties": {
          "id": { "type": "integer" },
          "email": { "type": "string" },
          "name": { "anyOf": [{ "type": "string" }, { "type": "null" }] },
          "status": { "$ref": "#/components/schemas/Status" },
          "role": {
            "anyOf": [
              { "$ref": "#/components/schemas/Admin" },
              { "$ref": "#/components/schemas/Guest" }
            ]
          },
          "tags": { "type": "array", "items": { "type": "string" } },
          "metadata": {
            "type": "object",
            "additionalProperties": { "type": "string" }
          }
        },
        "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" } },
          "level": { "type": "integer", "default": 1 }
        },
        "required": ["_tag"]
      },
      "Guest": {
        "type": "object",
        "properties": {
          "_tag": { "type": "string", "const": "Guest" },
          "expiresAt": { "anyOf": [{ "type": "string" }, { "type": "null" }] },
          "invitedBy": { "$ref": "#/components/schemas/User" }
        },
        "required": ["_tag"]
      },
      "ErrorDetails": {
        "type": "object",
        "properties": {
          "code": { "type": "string" },
          "message": { "type": "string" },
          "details": {
            "type": "object",
            "additionalProperties": { "type": "string" }
          }
        },
        "required": ["code", "message"]
      },
      "ApiResponse": {
        "type": "object",
        "properties": {
          "success": { "type": "boolean" },
          "data": {
            "anyOf": [{ "$ref": "#/components/schemas/User" }, { "type": "null" }]
          },
          "error": {
            "anyOf": [{ "$ref": "#/components/schemas/ErrorDetails" }, { "type": "null" }]
          }
        },
        "required": ["success"]
      }
    }
  }
}

Running npx osury openapi.json src/Schema.res produces the following ReScript code:

module S = Sury

@genType
@schema
type status = [#pending | #active | #blocked]

@genType
@schema
type admin = {
  permissions: option<array<string>>,
  level: int
}

@genType
@schema
type errorDetails = {
  code: string,
  message: string,
  details: option<Dict.t<string>>
}

@genType
@tag("_tag")
@schema
type adminOrGuest = Admin({
  permissions: option<array<string>>,
  level: int
}) | Guest({
  expiresAt: @s.null Nullable.t<string>,
  invitedBy: option<user>
})

@genType
@schema
type user = {
  id: int,
  email: string,
  name: @s.null Nullable.t<string>,
  status: option<status>,
  role: option<adminOrGuest>,
  tags: option<array<string>>,
  metadata: option<Dict.t<string>>
}

@genType
@schema
type guest = {
  expiresAt: @s.null Nullable.t<string>,
  invitedBy: option<user>
}

@genType
@schema
type apiResponse = {
  success: bool,
  data: @s.null Nullable.t<user>,
  error: @s.null Nullable.t<errorDetails>
}

@genType
@schema
type getUsersResponse = array<user>

The corresponding TypeScript output (generated from the same AST by osury's TypeScript emitter):

export type Nullable_t<T> = T | null;

export type status = "pending" | "active" | "blocked";

export type admin =
{
  readonly permissions: undefined | Array<string>;
  readonly level: number;
};

export type errorDetails =
{
  readonly code: string;
  readonly message: string;
  readonly details: undefined | Record<string, string>;
};

export type adminOrGuest =
| {
    readonly _tag: "Admin";
    readonly permissions: undefined | Array<string>;
    readonly level: number;
  }
| {
    readonly _tag: "Guest";
    readonly expiresAt: Nullable_t<string>;
    readonly invitedBy: undefined | user;
  };

export type user =
{
  readonly id: number;
  readonly email: string;
  readonly name: Nullable_t<string>;
  readonly status: undefined | status;
  readonly role: undefined | adminOrGuest;
  readonly tags: undefined | Array<string>;
  readonly metadata: undefined | Record<string, string>;
};

export type guest =
{
  readonly expiresAt: Nullable_t<string>;
  readonly invitedBy: undefined | user;
};

export type apiResponse =
{
  readonly success: boolean;
  readonly data: Nullable_t<user>;
  readonly error: Nullable_t<errorDetails>;
};

export type getUsersResponse = Array<user>;

Several things are worth noting here.

Nullability is handled correctly. The name field in User is anyOf: [string, null] in OpenAPI. osury generates @s.null Nullable.t<string>, which is option<T> in ReScript (works with Sury's null schema) and T | null in TypeScript. This is different from option<T> alone, which maps to T | undefined. The distinction matters: JSON APIs send null, not undefined.

Unions are extracted automatically. The role field is anyOf: [Admin, Guest]. osury detects the _tag discriminant on both variants, extracts the union into a standalone type adminOrGuest, and annotates it with @tag("_tag"). The generated TypeScript will be a proper discriminated union: { _tag: "Admin"; ... } | { _tag: "Guest"; ... }.

Default values promote fields to required. The level field on Admin has "default": 1 in the spec. osury treats this as a required field (no option<> wrapper) because Sury's runtime schema will apply the default if the value is missing.

Types are topologically sorted. status and errorDetails appear before user because user depends on them. This ensures the generated file compiles without forward reference issues.

Path responses are generated. The GET /users endpoint produces type getUsersResponse = array<user>, giving you a ready-to-use type for your API client layer.

Example 2: Discriminated Unions with Multiple Variants

Discriminated unions are where osury really shines. Consider a payment system with three payment methods:

{
  "openapi": "3.0.0",
  "info": { "title": "Payment API", "version": "1.0.0" },
  "paths": {
    "/payments": {
      "post": {
        "operationId": "createPayment",
        "responses": {
          "200": {
            "description": "Payment result",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/PaymentResult" }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "CreditCard": {
        "type": "object",
        "properties": {
          "_tag": { "type": "string", "const": "CreditCard" },
          "last4": { "type": "string" },
          "brand": { "type": "string" }
        },
        "required": ["_tag", "last4", "brand"]
      },
      "BankTransfer": {
        "type": "object",
        "properties": {
          "_tag": { "type": "string", "const": "BankTransfer" },
          "bankName": { "type": "string" },
          "iban": { "type": "string" }
        },
        "required": ["_tag", "bankName"]
      },
      "Crypto": {
        "type": "object",
        "properties": {
          "_tag": { "type": "string", "const": "Crypto" },
          "wallet": { "type": "string" },
          "network": {
            "type": "string",
            "enum": ["ethereum", "bitcoin", "solana"]
          }
        },
        "required": ["_tag", "wallet", "network"]
      },
      "PaymentMethod": {
        "anyOf": [
          { "$ref": "#/components/schemas/CreditCard" },
          { "$ref": "#/components/schemas/BankTransfer" },
          { "$ref": "#/components/schemas/Crypto" }
        ]
      },
      "PaymentResult": {
        "type": "object",
        "properties": {
          "id": { "type": "string" },
          "amount": { "type": "number" },
          "currency": { "type": "string" },
          "method": { "$ref": "#/components/schemas/PaymentMethod" },
          "status": {
            "type": "string",
            "enum": ["pending", "completed", "failed", "refunded"]
          },
          "createdAt": { "type": "string" },
          "metadata": {
            "type": "object",
            "additionalProperties": { "type": "string" }
          }
        },
        "required": ["id", "amount", "currency", "method", "status", "createdAt"]
      }
    }
  }
}

osury generates:

module S = Sury

@genType
@schema
type creditCard = {
  last4: string,
  brand: string
}

@genType
@schema
type bankTransfer = {
  bankName: string,
  iban: option<string>
}

@genType
@schema
type crypto = {
  wallet: string,
  network: [#ethereum | #bitcoin | #solana]
}

@genType
@tag("_tag")
@schema
type paymentMethod = CreditCard({
  last4: string,
  brand: string
}) | BankTransfer({
  bankName: string,
  iban: option<string>
}) | Crypto({
  wallet: string,
  network: [#ethereum | #bitcoin | #solana]
})

@genType
@schema
type paymentResult = {
  id: string,
  amount: float,
  currency: string,
  @as("method") method_: paymentMethod,
  status: [#pending | #completed | #failed | #refunded],
  createdAt: string,
  metadata: option<Dict.t<string>>
}

@genType
@schema
type postPaymentsResponse = paymentResult

And the generated TypeScript:

export type Nullable_t<T> = T | null;

export type creditCard =
{
  readonly last4: string;
  readonly brand: string;
};

export type bankTransfer =
{
  readonly bankName: string;
  readonly iban: undefined | string;
};

export type crypto =
{
  readonly wallet: string;
  readonly network: "ethereum" | "bitcoin" | "solana";
};

export type paymentMethod =
| {
    readonly _tag: "CreditCard";
    readonly last4: string;
    readonly brand: string;
  }
| {
    readonly _tag: "BankTransfer";
    readonly bankName: string;
    readonly iban: undefined | string;
  }
| {
    readonly _tag: "Crypto";
    readonly wallet: string;
    readonly network: "ethereum" | "bitcoin" | "solana";
  };

export type paymentResult =
{
  readonly id: string;
  readonly amount: number;
  readonly currency: string;
  readonly method: paymentMethod;
  readonly status: "pending" | "completed" | "failed" | "refunded";
  readonly createdAt: string;
  readonly metadata: undefined | Record<string, string>;
};

export type postPaymentsResponse = paymentResult;

Notice several details. The _tag field is not included in the generated record fields because @tag("_tag") handles it at the type level. The method field is renamed to method_ with @as("method") because method is a reserved word in ReScript. Inline enums like network and status are generated as polymorphic variants directly in the field type. And the paymentMethod union is a proper three-way discriminated union that you can pattern match on:

let describeMethod = (method: paymentMethod) =>
  switch method {
  | CreditCard({last4, brand}) => `${brand} ending in ${last4}`
  | BankTransfer({bankName}) => `Bank transfer via ${bankName}`
  | Crypto({wallet, network}) => `${(network :> string)} wallet ${wallet}`
  }

The @schema annotation means sury-ppx will generate a runtime validator for paymentMethod that automatically checks the _tag field and parses the correct variant. No manual schema writing needed.

Example 3: Pet Store with Reserved Words and Nested Types

Here is a classic pet store API that demonstrates reserved word handling and nested object references:

{
  "Pet": {
    "type": "object",
    "properties": {
      "id": { "type": "integer" },
      "name": { "type": "string" },
      "type": { "$ref": "#/components/schemas/PetType" },
      "owner": {
        "anyOf": [{ "$ref": "#/components/schemas/Owner" }, { "type": "null" }]
      },
      "vaccinations": {
        "type": "array",
        "items": { "$ref": "#/components/schemas/Vaccination" }
      }
    },
    "required": ["id", "name", "type"]
  },
  "PetType": {
    "type": "string",
    "enum": ["dog", "cat", "bird", "fish"]
  }
}

osury generates:

module S = Sury

@genType
@schema
type petType = [#dog | #cat | #bird | #fish]

@genType
@schema
type owner = {
  id: int,
  fullName: string,
  email: string,
  phone: @s.null Nullable.t<string>
}

@genType
@schema
type vaccination = {
  name: string,
  date: string,
  veterinarian: option<string>
}

@genType
@schema
type pet = {
  id: int,
  name: string,
  @as("type") type_: petType,
  owner: @s.null Nullable.t<owner>,
  vaccinations: option<array<vaccination>>
}

@genType
@schema
type getPetsResponse = pet

And the TypeScript output:

export type Nullable_t<T> = T | null;

export type petType = "dog" | "cat" | "bird" | "fish";

export type owner =
{
  readonly id: number;
  readonly fullName: string;
  readonly email: string;
  readonly phone: Nullable_t<string>;
};

export type vaccination =
{
  readonly name: string;
  readonly date: string;
  readonly veterinarian: undefined | string;
};

export type pet =
{
  readonly id: number;
  readonly name: string;
  readonly type: petType;
  readonly owner: Nullable_t<owner>;
  readonly vaccinations: undefined | Array<vaccination>;
};

export type getPetsResponse = pet;

Notice how the TypeScript side uses type directly as the field name (no renaming needed since type is not reserved in TypeScript), while ReScript requires type_ with @as("type"). The @as annotation ensures JSON serialization uses the original name, so both languages agree on the wire format.

The type field becomes type_ with an @as("type") annotation. This is transparent to the runtime — JSON serialization still uses "type" as the key — but it lets the ReScript code compile without conflicting with the language keyword.

How It Works Internally

osury's pipeline has three stages, producing both ReScript and TypeScript output from the same AST:

OpenAPI JSON → Schema AST → ReScript Code (.res)
                          → TypeScript Code (.ts)

Stage 1: Parsing. The OpenAPIParser module reads the OpenAPI document and extracts schemas from components.schemas and path responses. Each schema is parsed into an AST (Schema.schemaType) that represents the type structure. The parser recognizes nullable patterns (anyOf: [T, null]Nullable(T)), discriminated unions (objects with _tag.const), dictionary types (additionalProperties), and recursive references ($ref).

Stage 2: Union extraction. Before generating code, the Codegen module scans all schemas for inline Union types nested inside object fields. It extracts these into standalone named types using structural naming — Union([Ref("Admin"), Ref("Guest")]) becomes adminOrGuest. Identical union structures are deduplicated: if two different schemas both reference anyOf: [Admin, Guest], they share the same generated type.

Stage 3: Code generation. Types are topologically sorted using Kahn's algorithm so dependencies always appear before the types that reference them. Each type is rendered with appropriate annotations (@genType, @schema, @tag, @s.null, @as, @unboxed). Helper shims for Dict and Nullable are written alongside the main file.

TypeScript generation works from the same AST as the ReScript emitter, but targets TypeScript syntax. Discriminated unions become TypeScript discriminated unions with a literal _tag field. Optional fields use undefined | T (matching ReScript's option<T> runtime representation), while nullable fields use Nullable_t<T> which is T | null. Enums become string literal unions. The TypeScript output is available both through the CLI-generated @genType annotations (which produce .gen.tsx files when the ReScript compiler runs) and through osury's own TypeScript emitter (used in the demo playground). Both approaches produce structurally compatible output — one through ReScript's toolchain, the other directly.

The Nullable Problem, Solved

One of osury's most important design decisions is how it handles the difference between "missing" and "null" in JSON. In OpenAPI, a field can be absent from the response (optional) or present with a null value (nullable). JavaScript and TypeScript distinguish these: undefined vs null. ReScript's standard option<T> maps to undefined, which is wrong for nullable JSON fields.

osury introduces Nullable.t<T>, a type alias that is option<T> on the ReScript side (so it works with pattern matching and Sury's null schema) but maps to T | null on the TypeScript side via a shim file:

// Nullable.res
@genType.import(("./Nullable.shim.ts", "t"))
type t<'a> = option<'a>
// Nullable.shim.ts
export type t<T> = T | null;

Fields annotated with @s.null use this type, and Sury's runtime schema correctly handles JSON null values. This means you get correct behavior across the entire stack: ReScript code uses Some(value) / None, the compiled JavaScript sees value / null, and TypeScript types reflect T | null.

To illustrate the difference in the generated TypeScript, compare how osury handles optional vs nullable fields. An optional field like tags: option<array<string>> becomes readonly tags: undefined | Array<string> — the field may be absent from the JSON. A nullable field like name: @s.null Nullable.t<string> becomes readonly name: Nullable_t<string>, which expands to string | null — the field is present but may be JSON null. This distinction is invisible in many codegen tools but critical for correct API interaction.

Getting Started

Install osury as a dev dependency:

npm install -D osury

Make sure your project has the required peer dependencies (ReScript 12+, Sury 11+, sury-ppx, genType).

Generate types from your OpenAPI specification:

npx osury your-api.json src/Generated.res

This creates src/Generated.res with all types and schemas, plus the helper shims. Every time your API spec changes, re-run the command and the ReScript compiler will immediately catch any breaking changes in your frontend code.

Current Status

osury is in early development and is tailored to specific needs. It does not yet support circular references, the discriminator keyword from OpenAPI 3.x, or allOf with non-object types. It is published on npm and open source under the MIT license.

The project includes a local demo playground (npm run demo) where you can paste or upload an OpenAPI JSON and see the generated ReScript and TypeScript output side by side in your browser.

Contributions and suggestions are welcome at github.com/greenteamer/osury.