TL;DR
tsoa generates OpenAPI specs from TypeScript controllers — decorators on classes, automatic route generation, Express/Koa/Hapi support, type-safe request validation. swagger-jsdoc generates OpenAPI specs from JSDoc comments — annotate your existing routes with YAML in comments, framework-agnostic, minimal code changes. Zodios is the end-to-end type-safe API — define API contracts with Zod schemas, auto-generates OpenAPI, type-safe client and server, runtime validation. In 2026: tsoa for decorator-based controller APIs, swagger-jsdoc for documenting existing routes, Zodios for end-to-end type-safe APIs with Zod.
Key Takeaways
- tsoa: ~200K weekly downloads — decorators, controllers, auto route generation, validation
- swagger-jsdoc: ~500K weekly downloads — JSDoc comments, any framework, non-invasive
- Zodios: ~30K weekly downloads — Zod schemas, type-safe client+server, runtime validation
- tsoa generates both the OpenAPI spec AND the routes from controllers
- swagger-jsdoc only generates the spec — you write routes separately
- Zodios provides end-to-end type safety (contract-first)
tsoa
tsoa — decorator-based OpenAPI:
Controller definition
// src/controllers/PackagesController.ts
import {
Controller, Get, Post, Put, Delete, Route,
Tags, Body, Path, Query, Response, Security,
SuccessResponse,
} from "tsoa"
interface Package {
id: string
name: string
description: string
downloads: number
version: string
tags: string[]
}
interface CreatePackageRequest {
name: string
description: string
tags: string[]
}
@Route("packages")
@Tags("Packages")
export class PackagesController extends Controller {
/**
* Get all packages with optional filtering
* @param tag Filter by tag
* @param limit Max results (default 10)
*/
@Get()
public async getPackages(
@Query() tag?: string,
@Query() limit: number = 10
): Promise<Package[]> {
return await packageService.findAll({ tag, limit })
}
/**
* Get a package by name
* @param name The package name
*/
@Get("{name}")
@Response<{ message: string }>(404, "Package not found")
public async getPackage(
@Path() name: string
): Promise<Package> {
const pkg = await packageService.findByName(name)
if (!pkg) {
this.setStatus(404)
throw new Error("Package not found")
}
return pkg
}
/**
* Create a new package
*/
@Post()
@Security("bearerAuth")
@SuccessResponse(201, "Created")
public async createPackage(
@Body() body: CreatePackageRequest
): Promise<Package> {
this.setStatus(201)
return await packageService.create(body)
}
/**
* Update a package
*/
@Put("{name}")
@Security("bearerAuth")
public async updatePackage(
@Path() name: string,
@Body() body: Partial<CreatePackageRequest>
): Promise<Package> {
return await packageService.update(name, body)
}
/**
* Delete a package
*/
@Delete("{name}")
@Security("bearerAuth")
@SuccessResponse(204, "Deleted")
public async deletePackage(
@Path() name: string
): Promise<void> {
await packageService.delete(name)
this.setStatus(204)
}
}
Configuration
// tsoa.json
{
"entryFile": "src/server.ts",
"noImplicitAdditionalProperties": "throw-on-extras",
"controllerPathGlobs": ["src/controllers/**/*.ts"],
"spec": {
"outputDirectory": "src/generated",
"specVersion": 3,
"securityDefinitions": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
}
}
},
"routes": {
"routesDir": "src/generated",
"middleware": "express"
}
}
Generate and serve
# Generate OpenAPI spec + routes:
npx tsoa spec-and-routes
# Output:
# src/generated/swagger.json (OpenAPI spec)
# src/generated/routes.ts (Express routes with validation)
// src/server.ts
import express from "express"
import { RegisterRoutes } from "./generated/routes"
import swaggerUi from "swagger-ui-express"
import spec from "./generated/swagger.json"
const app = express()
app.use(express.json())
// Register auto-generated routes:
RegisterRoutes(app)
// Serve docs:
app.use("/docs", swaggerUi.serve, swaggerUi.setup(spec))
app.listen(3000)
swagger-jsdoc
swagger-jsdoc — JSDoc-based OpenAPI:
Basic setup
import swaggerJSDoc from "swagger-jsdoc"
const options: swaggerJSDoc.Options = {
definition: {
openapi: "3.0.0",
info: {
title: "PkgPulse API",
version: "1.0.0",
description: "Package comparison and analysis API",
},
servers: [
{ url: "http://localhost:3000", description: "Development" },
{ url: "https://api.pkgpulse.com", description: "Production" },
],
components: {
securitySchemes: {
bearerAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
},
},
},
},
apis: ["./src/routes/*.ts"], // Files with JSDoc comments
}
const spec = swaggerJSDoc(options)
Annotated routes
// src/routes/packages.ts
import { Router } from "express"
const router = Router()
/**
* @openapi
* /api/packages:
* get:
* tags: [Packages]
* summary: Get all packages
* parameters:
* - in: query
* name: tag
* schema:
* type: string
* description: Filter by tag
* - in: query
* name: limit
* schema:
* type: integer
* default: 10
* description: Max results
* responses:
* 200:
* description: List of packages
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: '#/components/schemas/Package'
*/
router.get("/api/packages", async (req, res) => {
const { tag, limit = 10 } = req.query
const packages = await packageService.findAll({ tag, limit: Number(limit) })
res.json(packages)
})
/**
* @openapi
* /api/packages/{name}:
* get:
* tags: [Packages]
* summary: Get a package by name
* parameters:
* - in: path
* name: name
* required: true
* schema:
* type: string
* description: Package name
* responses:
* 200:
* description: Package details
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Package'
* 404:
* description: Package not found
*/
router.get("/api/packages/:name", async (req, res) => {
const pkg = await packageService.findByName(req.params.name)
if (!pkg) return res.status(404).json({ error: "Not found" })
res.json(pkg)
})
/**
* @openapi
* /api/packages:
* post:
* tags: [Packages]
* summary: Create a package
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/CreatePackage'
* responses:
* 201:
* description: Package created
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Package'
*/
router.post("/api/packages", authMiddleware, async (req, res) => {
const pkg = await packageService.create(req.body)
res.status(201).json(pkg)
})
export default router
Schema definitions
/**
* @openapi
* components:
* schemas:
* Package:
* type: object
* required: [id, name, downloads]
* properties:
* id:
* type: string
* example: "pkg_123"
* name:
* type: string
* example: "react"
* description:
* type: string
* example: "UI library"
* downloads:
* type: integer
* example: 25000000
* version:
* type: string
* example: "19.0.0"
* tags:
* type: array
* items:
* type: string
* example: ["frontend", "ui"]
* CreatePackage:
* type: object
* required: [name]
* properties:
* name:
* type: string
* description:
* type: string
* tags:
* type: array
* items:
* type: string
*/
Serve docs
import express from "express"
import swaggerUi from "swagger-ui-express"
import swaggerJSDoc from "swagger-jsdoc"
const app = express()
const spec = swaggerJSDoc(options)
app.use("/docs", swaggerUi.serve, swaggerUi.setup(spec))
app.get("/openapi.json", (req, res) => res.json(spec))
Zodios
Zodios — end-to-end type-safe API:
API definition
import { makeApi, Zodios } from "@zodios/core"
import { z } from "zod"
// Define schemas:
const packageSchema = z.object({
id: z.string(),
name: z.string(),
description: z.string(),
downloads: z.number(),
version: z.string(),
tags: z.array(z.string()),
})
const createPackageSchema = z.object({
name: z.string().min(1),
description: z.string().optional(),
tags: z.array(z.string()).default([]),
})
// Define API contract:
const api = makeApi([
{
method: "get",
path: "/packages",
alias: "getPackages",
description: "Get all packages",
parameters: [
{ name: "tag", type: "Query", schema: z.string().optional() },
{ name: "limit", type: "Query", schema: z.number().default(10) },
],
response: z.array(packageSchema),
},
{
method: "get",
path: "/packages/:name",
alias: "getPackage",
description: "Get a package by name",
response: packageSchema,
errors: [
{ status: 404, schema: z.object({ message: z.string() }) },
],
},
{
method: "post",
path: "/packages",
alias: "createPackage",
description: "Create a package",
parameters: [
{ name: "body", type: "Body", schema: createPackageSchema },
],
response: packageSchema,
status: 201,
},
])
Type-safe client
import { Zodios } from "@zodios/core"
// Client (fully typed from API definition):
const client = new Zodios("https://api.pkgpulse.com", api)
// All calls are type-safe:
const packages = await client.getPackages({ queries: { tag: "frontend" } })
// packages: { id: string, name: string, ... }[]
const react = await client.getPackage({ params: { name: "react" } })
// react: { id: string, name: string, ... }
const newPkg = await client.createPackage({
name: "my-package",
description: "A great package",
tags: ["utilities"],
})
// newPkg: { id: string, name: string, ... }
Type-safe server (Express)
import { zodiosApp } from "@zodios/express"
const app = zodiosApp(api)
// Handlers are fully typed:
app.get("/packages", async (req, res) => {
// req.query.tag is typed as string | undefined
// req.query.limit is typed as number
const packages = await packageService.findAll({
tag: req.query.tag,
limit: req.query.limit,
})
res.json(packages) // Must return Package[]
})
app.get("/packages/:name", async (req, res) => {
// req.params.name is typed as string
const pkg = await packageService.findByName(req.params.name)
if (!pkg) return res.status(404).json({ message: "Not found" })
res.json(pkg)
})
app.post("/packages", async (req, res) => {
// req.body is typed and validated as CreatePackage
const pkg = await packageService.create(req.body)
res.status(201).json(pkg)
})
app.listen(3000)
OpenAPI generation
import { openApiBuilder } from "@zodios/openapi"
// Generate OpenAPI spec from API definition:
const document = openApiBuilder({
title: "PkgPulse API",
version: "1.0.0",
description: "Package comparison and analysis API",
})
.addServer({ url: "https://api.pkgpulse.com" })
.addPublicApi(api)
.build()
// document is a valid OpenAPI 3.0 spec
// Serve with any docs renderer (Scalar, Swagger UI, etc.)
Feature Comparison
| Feature | tsoa | swagger-jsdoc | Zodios |
|---|---|---|---|
| Approach | Decorators | JSDoc comments | Zod schemas |
| OpenAPI generation | ✅ (auto) | ✅ (from comments) | ✅ (from schemas) |
| Route generation | ✅ (auto) | ❌ (manual) | ❌ (manual) |
| Request validation | ✅ (auto) | ❌ (manual) | ✅ (Zod runtime) |
| Response validation | ❌ | ❌ | ✅ (Zod runtime) |
| Type-safe client | ❌ | ❌ | ✅ |
| Type-safe server | ✅ (controller types) | ❌ | ✅ |
| Framework | Express, Koa, Hapi | Any | Express |
| Code-first | ✅ | ✅ | ✅ (contract-first) |
| Invasiveness | High (decorators) | Low (comments) | Medium (schemas) |
| Learning curve | Medium | Low | Medium |
| TypeScript | ✅ (required) | ✅ (optional) | ✅ (required) |
| Weekly downloads | ~200K | ~500K | ~30K |
When to Use Each
Use tsoa if:
- Want auto-generated routes AND OpenAPI spec from controllers
- Prefer decorator-based API definitions
- Building Express, Koa, or Hapi APIs
- Want automatic request validation from TypeScript types
Use swagger-jsdoc if:
- Want to document existing routes without changing code structure
- Need the least invasive OpenAPI generation
- Working with any HTTP framework
- Prefer keeping spec close to route handlers as comments
Use Zodios if:
- Want end-to-end type safety (client + server from one definition)
- Already using Zod for validation
- Want runtime request AND response validation
- Building APIs where type safety is critical
Runtime Request Validation and Security
The security implications of how each approach handles request validation differ significantly. tsoa generates Express route handlers that validate incoming requests against the TypeScript types defined in your controller methods — if a request body contains an unexpected field, the noImplicitAdditionalProperties: "throw-on-extras" option rejects the request before it reaches your controller code. This prevents a class of mass assignment vulnerabilities where unexpected fields in the request body could be used to modify protected properties. swagger-jsdoc generates only the OpenAPI spec; validation is entirely separate and must be implemented manually using a library like express-validator or a schema validation middleware. The gap between the documented spec and the actual runtime behavior is a maintenance hazard — if a route's validation logic diverges from the JSDoc annotation, the spec becomes misleading documentation rather than an accurate contract. Zodios's @zodios/express implementation validates all requests and responses using the Zod schemas in the API definition, providing the strongest runtime validation guarantees because the same schema that types your code also validates the actual request at runtime.
TypeScript Integration Patterns and Code Generation
Each approach has a distinct relationship with TypeScript's type system. tsoa reads TypeScript type annotations at build time and converts them to JSON Schema for the OpenAPI spec and runtime validation. This means TypeScript is the single source of truth — you define the type once, and tsoa derives the spec and validation from it. The limitation is that tsoa supports a subset of TypeScript's type system (union types, interfaces, enums, and primitives work; complex conditional types and template literal types do not), which can force you to simplify types for API boundary use. Zodios takes the opposite approach: Zod schemas are the source of truth, and TypeScript types are inferred from them using z.infer<>. This is the zod-first development style that has become increasingly popular, where runtime validation and TypeScript types are unified in one definition. swagger-jsdoc has no TypeScript integration at the specification level — the JSDoc YAML annotations are strings from TypeScript's perspective, and no type errors are produced when the annotation doesn't match the route handler's actual TypeScript types.
OpenAPI Spec Quality and Tooling Compatibility
The quality of the generated OpenAPI spec affects downstream tooling — client generation, mock server creation, documentation rendering, and API gateway import. tsoa produces a well-structured OpenAPI 3.0 spec with $ref references for reused schemas, which is important for keeping the spec readable and for code generators that need schema references to avoid duplicating types. The generated spec includes security definitions, response schemas for non-200 status codes, and proper parameter classification (path, query, body). swagger-jsdoc spec quality depends entirely on the quality of the JSDoc annotations — teams that invest in detailed annotations produce excellent specs, while teams that maintain annotations minimally end up with incomplete specs missing response schemas and error codes. Zodios's openApiBuilder generates a valid OpenAPI 3.0 spec from the API definition, with schemas automatically derived from the Zod validators and response types accurately reflecting the defined response schemas including error cases.
Contract-First vs Code-First API Development
The architectural philosophy underlying each approach has workflow implications beyond the technical implementation. tsoa and swagger-jsdoc represent code-first approaches — you write the server code and derive the spec from it. This works well for teams where backend developers own the API design and the spec is generated as a documentation artifact. Zodios represents something closer to a contract-first approach — the API definition is a standalone contract that can be written before either client or server implementation, and both consume the same definition. This enables a workflow where a tech lead defines the API contract in a shared package, frontend developers build a typed client against the contract, and backend developers implement typed handlers against the same contract, with both sides verifiable against the contract at compile time. For teams with separate frontend and backend developers who need to work in parallel, the Zodios contract-first model significantly reduces the integration friction that typically emerges when the server implementation diverges from what the client expected.
Production Documentation and API Discovery
Serving accurate, up-to-date API documentation in production requires integrating the spec generation into your deployment pipeline. tsoa's npx tsoa spec-and-routes command should run as part of your build step, ensuring the generated spec file is always current with the controller code. The spec is then bundled with the deployment and served statically, either via swagger-ui-express, Scalar (a modern alternative), or Redoc. swagger-jsdoc generates the spec at runtime by scanning the route files — this means the spec is always current with the running code, but it adds startup cost and requires that the route files are accessible at server startup. Zodios's spec generation is also a build-time operation using openApiBuilder, and the resulting JSON spec should be committed to the repository as a generated artifact or served dynamically from an endpoint. For API gateways like Kong, AWS API Gateway, or Cloudflare API Shield that can import OpenAPI specs to configure routing and validation rules, all three approaches produce compatible output that can be imported directly.
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on tsoa v6.x, swagger-jsdoc v6.x, and @zodios/core v10.x.
Compare API tooling and TypeScript libraries on PkgPulse →
See also: Yup vs Zod and Superstruct vs Zod, change-case vs camelcase vs slugify.