DocTreen
Features

Typed codegen

npx doctreen codegen — strict TypeScript types and a zero-dependency typed fetch client from any OpenAPI doc.

v1.13

Take the OpenAPI 3.x document DocTreen already emits at /docs/openapi.json and generate two things:

  1. A strict TypeScript declaration file — one export interface per components.schemas entry, plus per-operation …Params / …Query / …Body / …Response shapes.
  2. 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.com

The 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 typed

The 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.schemas becomes top-level interfaces named exactly as they appear in the spec. With DocTreen, that means defineSchema('User', …) round-trips as interface User, and the anonymous-duplicate auto-promotion from v1.11 keeps the namespace tidy. As of v1.15, Zod-defined named schemas emit $ref too, so defineSchema('User', z.object(…)) becomes export interface User instead of an anonymous Schema1 / 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.
  • Operations is 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.
  • required drives optionality. A property listed in required is 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. params appears when the path has parameters; query when query parameters exist; body when there's a JSON request body. When everything is optional the whole args defaults to {} so await 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 doctreen or 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/router for Koa). It's resolved from module.exports, its default, or an explicit --export <name>. Nothing listens on a port.
  • Fastify: call fastifyAdapter(fastify) before your routes and export a promise that resolves after await fastify.ready().
  • Nest: export the created INestApplication.
  • Optional flags: --out (default openapi.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.ts

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).

OperationTS interface base nameClient method name
getUserById (operationId)GetUserByIdgetUserById
GET /usersGetUsersgetUsers
GET /users/{id}GetUsersByIdgetUsersById
POST /users/{userId}/postsPostUsersByUserIdPostspostUsersByUserIdPosts
DELETE /admin/users/{id}DeleteAdminUsersByIddeleteAdminUsersById

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 constructTypeScript output
type: string / integer / number / boolean / nullstring / number / number / boolean / null
type: arrayT[] (parenthesised for unions)
type: object + propertiesinline { … } 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)
discriminatorignored (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   --watch

The 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

FlagPurpose
--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/json media type. Operations that only declare multipart/form-data or application/octet-stream get an empty body / unknown response — call those via fetch directly.
  • No discriminated unions yet. oneOf with a discriminator becomes 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. string with format: 'date-time' stays string. A separate opt-in flag for branded date / uuid / email types is on the roadmap.
  • No retries or caching. Those belong in the fetch you inject, not in the generated client.

On this page