tsoa vs swagger-jsdoc vs Zodios: OpenAPI Spec Generation in TypeScript (2026)
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
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.