Skip to main content

tsoa vs swagger-jsdoc vs Zodios: OpenAPI Spec Generation in TypeScript (2026)

·PkgPulse Team

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

Featuretsoaswagger-jsdocZodios
ApproachDecoratorsJSDoc commentsZod 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)
FrameworkExpress, Koa, HapiAnyExpress
Code-first✅ (contract-first)
InvasivenessHigh (decorators)Low (comments)Medium (schemas)
Learning curveMediumLowMedium
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.

Compare API tooling and TypeScript libraries on PkgPulse →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.