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.

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

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