Typed codegen
npx doctreen codegen — strict TypeScript types and a zero-dependency typed fetch client from any OpenAPI doc.
Take the OpenAPI 3.x document DocTreen already emits at /docs/openapi.json
and generate two things:
- A strict TypeScript declaration file — one
export interfacepercomponents.schemasentry, plus per-operation…Params/…Query/…Body/…Responseshapes. - A zero-dependency typed fetch client — one async method per operation, request and response inferred end-to-end, errors as values.
# Types only
npx doctreen codegen types --from http://localhost:3000/docs --out src/api/types.d.ts
# A typed fetch client that imports those types
npx doctreen codegen client --from http://localhost:3000/docs --out src/api/client.ts \
--base-url https://api.example.comThe CLI reads /openapi.json, not your runtime registry, so it works with
any OpenAPI 3.x document — DocTreen-emitted or otherwise. Use it across a
polyglot backend, against a vendor spec, or to consume your own DocTreen
service from a separate frontend repo.
The mental model
┌─────────────────────────┐
│ /docs/openapi.json │ ← DocTreen exports this (or any 3.x spec)
└────────────┬────────────┘
│
▼
┌─────────────────────────┐
│ npx doctreen codegen │
└─────┬─────────────┬─────┘
│ │
▼ ▼
types.d.ts client.ts
(interfaces) (createClient)
│ │
└──────┬──────┘
▼
Your app code, fully typedThe two files are independent — emit just types.d.ts if you already
have a HTTP client you like, or just client.ts if you don't care about
exposing the named interfaces. When you generate both, client.ts
imports its types from a configurable path (default ./types).
Anatomy of the generated files
Given an OpenAPI snippet like this:
{
"components": {
"schemas": {
"User": {
"type": "object",
"required": ["id", "name", "email"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"email": { "type": "string", "format": "email" },
"role": { "type": "string", "enum": ["admin", "viewer"] }
}
}
}
},
"paths": {
"/users/{id}": {
"get": {
"operationId": "getUserById",
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": {
"200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } } }
}
}
},
"/users": {
"post": {
"operationId": "createUser",
"requestBody": {
"required": true,
"content": { "application/json": { "schema": {
"type": "object",
"required": ["name", "email"],
"properties": {
"name": { "type": "string" },
"email": { "type": "string" }
}
}}}
},
"responses": {
"201": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } } }
}
}
}
}
}types.d.ts
// Generated by `doctreen codegen types`. Do not edit by hand.
export interface User {
id: number;
name: string;
email: string;
role?: "admin" | "viewer";
}
export interface GetUserByIdParams {
id: string;
}
export type GetUserByIdResponse = User;
export interface CreateUserBody {
name: string;
email: string;
}
export type CreateUserResponse = User;
export interface Operations {
getUserById: { input: { params: GetUserByIdParams }; output: GetUserByIdResponse };
createUser: { input: { body: CreateUserBody }; output: CreateUserResponse };
}Things to notice:
components.schemasbecomes top-level interfaces named exactly as they appear in the spec. With DocTreen, that meansdefineSchema('User', …)round-trips asinterface User, and the anonymous-duplicate auto-promotion from v1.11 keeps the namespace tidy.- Operation type names are derived from
operationId(PascalCase), with a stable fallback when none is present (see Naming). $refs become identifier references — the generator never inlines a named schema, so consumers can compare values across endpoints by type identity.Operationsis a typed dispatch table — handy if you're writing a generic adapter, an MSW handler, or your own client on top of the same types.requireddrives optionality. A property listed inrequiredis emitted without?; everything else is optional.
client.ts
// Generated by `doctreen codegen client`. Do not edit by hand.
import type {
CreateUserBody,
CreateUserResponse,
GetUserByIdParams,
GetUserByIdResponse,
} from "./types";
export interface DoctreenClientOptions {
baseUrl?: string;
fetch?: typeof fetch;
headers?: Record<string, string>;
onRequest?: (req: { method: string; url: string; init: RequestInit }) => void | Promise<void>;
}
export class DoctreenHttpError extends Error {
readonly status: number;
readonly body: unknown;
constructor(status: number, body: unknown, message: string) { /* … */ }
}
export function createClient(options: DoctreenClientOptions = {}) {
// …request() impl: path-param substitution, query string, JSON body,
// header merge, content-type defaulting, JSON parse, error wrap…
return {
getUserById: (args: { params: GetUserByIdParams; headers?: Record<string, string> }) =>
request("GET", "/users/{id}", { params: args.params, headers: args.headers }) as Promise<GetUserByIdResponse>,
createUser: (args: { body: CreateUserBody; headers?: Record<string, string> }) =>
request("POST", "/users", { headers: args.headers, body: args.body }) as Promise<CreateUserResponse>,
};
}Things to notice:
- One method per operation, named with the camelCase form of
operationId(or method + path if no id). - The argument object only carries keys the operation actually
declares.
paramsappears when the path has parameters;querywhen query parameters exist;bodywhen there's a JSON request body. When everything is optional the wholeargsdefaults to{}soawait api.getUsers()works without an empty object. headers?is always available as an escape hatch for per-request auth tokens, idempotency keys,If-None-Match, etc.- The return type matches the JSON 2xx response. When no 2xx
response is declared the method returns
Promise<unknown>— TypeScript forces you to narrow before consuming. - The whole file is self-contained — no runtime imports from
doctreenor anywhere else. You can copy it into a repo that doesn't even have DocTreen installed.
Using the client
Basic call
import { createClient } from './api/client';
const api = createClient({ baseUrl: 'https://api.example.com' });
const user = await api.getUserById({ params: { id: '42' } });
// ^? GetUserByIdResponse — { id: number; name: string; email: string; role?: ... }Authentication
There are three places to inject auth, in order of breadth:
// 1. Global, baked at construction — tokens that don't change per request.
const api = createClient({
baseUrl: 'https://api.example.com',
headers: { authorization: `Bearer ${apiKey}` },
});
// 2. Per-request override — anything in `args.headers` wins over the global.
await api.getUserById({
params: { id: '42' },
headers: { authorization: `Bearer ${userJwt}` },
});
// 3. Dynamic — mutate the RequestInit in `onRequest` to read a refresh
// token from a store every call.
const api2 = createClient({
baseUrl: 'https://api.example.com',
onRequest: async ({ init }) => {
const token = await tokenStore.get();
(init.headers as Record<string, string>).authorization = `Bearer ${token}`;
},
});Bring your own fetch
The fetch? option swaps the global fetch for any compatible
implementation. Use it for retries, timeouts, request logging, or to
plug in undici's pool, cross-fetch, or a mocked fetch in tests:
import pRetry from 'p-retry';
const api = createClient({
baseUrl: 'https://api.example.com',
fetch: (input, init) => pRetry(() => fetch(input, init), { retries: 3 }),
});Error handling
import { createClient, DoctreenHttpError } from './api/client';
try {
await api.getUserById({ params: { id: 'does-not-exist' } });
} catch (err) {
if (err instanceof DoctreenHttpError) {
if (err.status === 404) {
// err.body is the parsed JSON (or string if not JSON)
console.warn('user not found:', err.body);
return null;
}
if (err.status >= 500) throw err;
}
throw err;
}DoctreenHttpError is the only thrown error class — network failures
propagate as whatever the underlying fetch throws (commonly
TypeError: fetch failed).
Naming
The generator prefers operationId when present, falling back to a
deterministic derivation from method + path. The same rule produces
both the TypeScript identifier (PascalCase) and the client method
name (camelCase).
| Operation | TS interface base name | Client method name |
|---|---|---|
getUserById (operationId) | GetUserById | getUserById |
GET /users | GetUsers | getUsers |
GET /users/{id} | GetUsersById | getUsersById |
POST /users/{userId}/posts | PostUsersByUserIdPosts | postUsersByUserIdPosts |
DELETE /admin/users/{id} | DeleteAdminUsersById | deleteAdminUsersById |
Set operationId on every operation if you want short, readable method
names — defineRoute({ operationId: 'createUser', ... }) on DocTreen, or
the equivalent on whichever spec generator you use. The exporter already
synthesises an operationId so the codegen output stays stable
release-to-release.
OpenAPI feature support
The Schema Object → TypeScript translation handles the constructs you actually see in modern specs:
| OpenAPI construct | TypeScript output |
|---|---|
type: string / integer / number / boolean / null | string / number / number / boolean / null |
type: array | T[] (parenthesised for unions) |
type: object + properties | inline { … } interface body |
required: [...] | drives ? on absent keys |
enum: [...] | literal union |
$ref: '#/components/schemas/Foo' | Foo (named type reference) |
allOf: [A, B] | (A) & (B) |
oneOf / anyOf | (A) | (B) |
nullable: true (OpenAPI 3.0) | T | null |
type: ['string', 'null'] (OpenAPI 3.1) | string | null |
additionalProperties: true | [key: string]: unknown |
additionalProperties: { type: 'X' } | [key: string]: X |
format: 'email' | 'date-time' | … | ignored (stays string) |
discriminator | ignored (treat as plain oneOf) |
The output passes tsc --strict cleanly on real-world DocTreen specs.
Workflow patterns
Dev loop
Run codegen alongside your dev server in --watch mode and your editor
picks up new types within a couple of seconds:
# Terminal 1
node server.js
# Terminal 2 — re-poll /openapi.json every 2s, skip writes when unchanged
npx doctreen codegen types --from http://localhost:3000/docs --out src/api/types.d.ts --watch
npx doctreen codegen client --from http://localhost:3000/docs --out src/api/client.ts --watchThe write is gated on byte-equality with the previous output, so editor file-watchers, TS server, and HMR don't churn on every poll.
CI / build
Run codegen once at build time when you don't want the generated files in source control:
{
"scripts": {
"predev": "doctreen codegen types --from ./openapi.json --out src/api/types.d.ts",
"prebuild": "doctreen codegen types --from ./openapi.json --out src/api/types.d.ts"
}
}Monorepo
Frontend consuming a sibling backend? Point --from at the backend's
exported openapi.json (checked into the backend package on each
release), keep the generated client in the frontend package, and bump
together. The output is stable byte-for-byte, so diffs are reviewable.
# In the frontend package
doctreen codegen types --from ../backend/openapi.json --out src/api/types.d.ts
doctreen codegen client --from ../backend/openapi.json --out src/api/client.ts \
--base-url "$VITE_API_URL"Check in vs. regenerate
Both work. Check in when you want PRs that touch the API surface to show up as visible changes to consumers — useful in monorepos and small teams. Regenerate at build time when the backend lives in another repo and you'd rather not couple a release to a frontend commit.
Flags
| Flag | Purpose |
|---|---|
--from <src> | URL (auto-appends /openapi.json) or local JSON file. Required. |
--out <path> | Output file. Required. |
--base-url <url> | Default baseUrl baked into the generated client (client only). |
--types-import <path> | Module path the client imports types from. Default ./types. |
--watch [ms] | Re-generate on change. URL poll interval in ms (default 2000). |
Programmatic API
const { generateTypes, generateClient, loadOpenApiDoc } = require('doctreen/codegen');
const doc = await loadOpenApiDoc('http://localhost:3000/docs');
// or: const doc = JSON.parse(fs.readFileSync('./openapi.json', 'utf8'));
const types = generateTypes(doc);
const client = generateClient(doc, {
baseUrl: 'https://api.example.com',
typesImportPath: './types',
});
require('fs').writeFileSync('src/api/types.d.ts', types);
require('fs').writeFileSync('src/api/client.ts', client);Useful when you want to wire codegen into a larger build script — post-process the output, generate per-tenant clients with different base URLs, or fold codegen into a Nx / Turborepo task graph.
Notes & limitations
- OpenAPI input only. The CLI reads
/openapi.json, not the live registry. This keeps the input surface uniform across frameworks and lets the same command target non-DocTreen OpenAPI docs. - Output is byte-stable. Same input doc → same output bytes. Safe to check in; diffs are reviewable.
- JSON only. Request and response bodies are typed from the
application/jsonmedia type. Operations that only declaremultipart/form-dataorapplication/octet-streamget an empty body /unknownresponse — call those viafetchdirectly. - No discriminated unions yet.
oneOfwith adiscriminatorbecomes a plain TypeScript union — you still need to narrow by inspecting fields. A future release will emit a discriminator-aware union when the spec provides one. - No branded types for
format.stringwithformat: 'date-time'staysstring. A separate opt-in flag for branded date / uuid / email types is on the roadmap. - No retries or caching. Those belong in the
fetchyou inject, not in the generated client.