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.
Languages: the npm CLI emits TypeScript; the PHP package emits the same two artifacts natively — readonly DTOs and a zero-dependency typed client:
vendor/bin/doctreen codegen types --from openapi.json --out src/Api/Types.php --namespace 'App\Api'
vendor/bin/doctreen codegen client --from openapi.json --out src/Api/Client.php --namespace 'App\Api'Both read plain OpenAPI 3.x, so you can point the TypeScript generator at a Flask or Laravel service (and the PHP generator at a Node service) freely. Python client codegen is planned.
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. As of v1.15, Zod-defined named schemas emit$reftoo, sodefineSchema('User', z.object(…))becomesexport interface Userinstead of an anonymousSchema1/Schema2.- 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.
v1.15 improvements v1.15
Three changes land in the generated output when your source spec comes from DocTreen v1.15.
Named schemas → real types
Because Zod-defined named schemas now emit $ref,
codegen produces export interface User instead of anonymous Schema1 /
Schema2. Generated types read the way you named them, and reused shapes
collapse to a single interface referenced by name across every operation.
Deep schemas keep their leaf types
The Zod → SchemaNode conversion depth cap was raised from 5 to 12, so
deep-but-non-recursive schema trees no longer collapse to unknown past the
old limit — their leaf types survive all the way into the generated output.
DoctreenValidationError
Routes that declare Zod validators document their 422 { error, issues[] }
body as a named DoctreenValidationError component, so codegen emits a
matching interface:
export interface DoctreenValidationError {
error: string;
issues: { path: string; message: string; code: string }[];
}Import it in your error handling instead of guessing the validation-error shape:
import type { DoctreenValidationError } from './api/types';
import { DoctreenHttpError } from './api/client';
try {
await api.createUser({ body: { name: '', email: 'nope' } });
} catch (err) {
if (err instanceof DoctreenHttpError && err.status === 422) {
const { issues } = err.body as DoctreenValidationError;
for (const issue of issues) console.warn(issue.path, issue.message);
}
}Offline OpenAPI emit v1.15
codegen --from ./openapi.json was already offline — it reads a JSON file,
not a live server. As of v1.15 you can produce that file offline too, so
the whole codegen pipeline runs in CI without booting your app server.
Every adapter exports getOpenApiDocument(app, config), which builds the
OpenAPI document in-process — no app.listen(), no HTTP request. A new CLI
wraps it:
doctreen emit-openapi --adapter express --app ./app.js --out openapi.json--adapter accepts express, fastify, hono, koa, or nest.
--app <module>must export the app (or the@koa/routerfor Koa). It's resolved frommodule.exports, itsdefault, or an explicit--export <name>. Nothing listens on a port.- Fastify: call
fastifyAdapter(fastify)before your routes and export a promise that resolves afterawait fastify.ready(). - Nest: export the created
INestApplication. - Optional flags:
--out(defaultopenapi.json),--title,--doc-version.
Programmatic equivalent — call the same function the CLI uses:
const { getOpenApiDocument } = require('doctreen/express');
const app = require('./app');
const doc = getOpenApiDocument(app, { title: 'My API', version: '1.0.0' });
require('fs').writeFileSync('openapi.json', JSON.stringify(doc, null, 2));Chain it straight into codegen for a server-free CI run:
doctreen emit-openapi --adapter express --app ./app.js --out openapi.json
doctreen codegen types --from ./openapi.json --out src/api/types.d.ts
doctreen codegen client --from ./openapi.json --out src/api/client.tsUsing 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.