Decorator-first OpenAPI generation for TypeScript classes and functions using Zod v4 schemas.
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.
- Zod v4+ only
@openapi(...)method decorator andopenapi(...)(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
zwith.openapi(...)already enabled
- Node.js 20.17 or newer
- Zod 4
bun add @ngandu-dev/zod-openapi zodIf your project uses legacy decorators, enable them in tsconfig.json:
{
"compilerOptions": {
"experimentalDecorators": true
}
}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.
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.
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.
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.
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 underhandlers. - 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/:idand/users/{id}are considered the same path.
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",
},
});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",
},
},
});Install dependencies with bun install, then run bun run quality before opening a pull request.
See CONTRIBUTING.md for the complete contribution workflow.
Run bun run test for the test suite or bun run test:coverage for a coverage report.
Contributions are welcome. Please read CONTRIBUTING.md and follow our Code of Conduct.
Please report vulnerabilities privately as described in SECURITY.md.
Released under the MIT License.