Skip to content

Repository files navigation

Zod OpenAPI

Decorator-first OpenAPI generation for TypeScript classes and functions using Zod v4 schemas.

npm npm Quality Tests GitHub


This package is designed for legacy or incremental migrations where documentation should stay as metadata on controller methods and route functions instead of becoming application middleware. It uses @asteasolutions/zod-to-openapi under the hood and keeps the authoring experience centered on a single openapi(...) API.

Features

  • Zod v4+ only
  • @openapi(...) method decorator and openapi(...)(handler) function wrapper
  • Explicit controller/handler registration and opt-in automatic discovery
  • Request body shorthand for the common JSON case
  • OpenAPI 3.0 and 3.1 document generation
  • Re-exports z with .openapi(...) already enabled

Requirements

  • Node.js 20.17 or newer
  • Zod 4

Installation

bun add @ngandu-dev/zod-openapi zod

If your project uses legacy decorators, enable them in tsconfig.json:

{
  "compilerOptions": {
    "experimentalDecorators": true
  }
}

Quick Start

Define a route in its application module. The wrapper attaches OpenAPI metadata and returns the original function with its full TypeScript signature intact:

// src/routes/users.ts
import { openapi, z } from "@ngandu-dev/zod-openapi";

const UserParamsSchema = z.object({
  user_id: z.string().min(1),
});

const PermissionsSchema = z
  .object({
    permissions: z.array(z.string()),
  })
  .openapi("Permissions");

const UpdatePermissionsResponseSchema = z.object({
  id: UserParamsSchema.shape.user_id,
});

export const updatePermissions = openapi({
  method: "put",
  path: "/api/users/:user_id/permissions",
  tags: ["Users"],
  summary: "Update User Permissions",
  description: "Update permissions for a specific user by their ID.",
  request: {
    params: UserParamsSchema,
    body: PermissionsSchema,
  },
  responses: {
    200: {
      description: "Updated permissions",
      content: {
        "application/json": {
          schema: UpdatePermissionsResponseSchema,
        },
      },
    },
  },
})(async function updatePermissions(input: {
  userId: string;
  permissions: string[];
}) {
  return {
    id: input.userId,
  };
});

Import the route module and generate the document with automatic discovery:

// src/openapi.ts
import "./routes/users";

import { generateOpenApiDocument } from "@ngandu-dev/zod-openapi";

export const openApiDocument = generateOpenApiDocument({
  discovery: "auto",
  document: {
    openapi: "3.0.0",
    info: {
      title: "Example API",
      version: "1.0.0",
    },
  },
});

console.log(JSON.stringify(openApiDocument, null, 2));

openApiDocument.paths contains /api/users/{user_id}/permissions even though the route used the Express-style :user_id path. The registered Permissions schema is emitted under components.schemas and referenced from the request body.

Automatic discovery is runtime registration, not filesystem scanning. A route module must be imported so that its openapi(...) wrapper or decorators execute.

Explicit Handler Registration

Use handlers when you want a document containing a controlled set of function routes. Explicit discovery is the default:

import { generateOpenApiDocument } from "@ngandu-dev/zod-openapi";

import { updatePermissions } from "./routes/users";

const document = generateOpenApiDocument({
  handlers: [updatePermissions],
  document: {
    openapi: "3.0.0",
    info: {
      title: "Example API",
      version: "1.0.0",
    },
  },
});

controllers, handlers, and routes can be combined in the same document. Passing a function without OpenAPI metadata under handlers throws a descriptive error.

Controller Routes

Class controllers remain supported for legacy applications and incremental migrations:

import { generateOpenApiDocument, openapi } from "@ngandu-dev/zod-openapi";

class HealthController {
  @openapi({
    method: "get",
    path: "/health",
    tags: ["System"],
    summary: "Health check",
    responses: {
      200: {
        description: "OK",
      },
    },
  })
  health() {
    return { ok: true };
  }
}

const document = generateOpenApiDocument({
  controllers: [HealthController],
  document: {
    openapi: "3.0.0",
    info: {
      title: "Example API",
      version: "1.0.0",
    },
  },
});

Controller classes, instances, inherited methods, and static methods are supported. Controller decorators are also included by discovery: "auto" after their module has been imported.

Migration Notes

Migrating to @ngandu-dev/zod-openapi

Replace the dependency and every import with @ngandu-dev/zod-openapi. Version 2 is a clean package move and does not provide an alias for the old scope.

Migrating route discovery

Existing controller usage remains compatible and the default discovery mode is still "explicit"; applications using controllers: [UsersController] do not need to change.

  • Add standalone routes incrementally with openapi(route)(handler) and list them under handlers.
  • Switch to discovery: "auto" only after importing every module that declares documented routes.
  • controllers, handlers, and automatic discovery can be combined. Identical handler registrations are deduplicated.
  • Automatic registrations live for the lifetime of the current process. Prefer explicit sources when generating unrelated documents or when tests require strict isolation.
  • Duplicate HTTP method/path operations now throw a descriptive error instead of being silently overwritten. Express-style and OpenAPI-style forms such as /users/:id and /users/{id} are considered the same path.

Generating a Registry First

If you want to register extra components or mix manual routes with decorated ones, build a registry explicitly:

import {
  OpenApiGeneratorV3,
  createOpenApiRegistry,
} from "@ngandu-dev/zod-openapi";

import "./routes/users";

const registry = createOpenApiRegistry({
  discovery: "auto",
  routes: [
    {
      method: "get",
      path: "/health",
      tags: ["System"],
      summary: "Health check",
      responses: {
        200: {
          description: "OK",
        },
      },
    },
  ],
  register(registry) {
    registry.registerComponent("securitySchemes", "bearerAuth", {
      type: "http",
      scheme: "bearer",
      bearerFormat: "JWT",
    });
  },
});

const document = new OpenApiGeneratorV3(registry.definitions).generateDocument({
  openapi: "3.0.0",
  info: {
    title: "Example API",
    version: "1.0.0",
  },
});

OpenAPI 3.1

Use generateOpenApi31Document(...) when you want a 3.1 document:

import "./routes/users";

import { generateOpenApi31Document } from "@ngandu-dev/zod-openapi";

const document = generateOpenApi31Document({
  discovery: "auto",
  document: {
    openapi: "3.1.0",
    info: {
      title: "Example API",
      version: "1.0.0",
    },
  },
});

Development

Install dependencies with bun install, then run bun run quality before opening a pull request. See CONTRIBUTING.md for the complete contribution workflow.

Testing

Run bun run test for the test suite or bun run test:coverage for a coverage report.

Contributing

Contributions are welcome. Please read CONTRIBUTING.md and follow our Code of Conduct.

Security

Please report vulnerabilities privately as described in SECURITY.md.

License

Released under the MIT License.

Contributors

Contributors

About

Decorator-first OpenAPI generation for TypeScript controllers using Zod v4 schemas.

Resources

Code of conduct

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages