Right, so you’ve built a beautiful, type-safe API with TypeScript, Express, and Fastify. It feels solid. You’re not sending strings that should be integers or booleans that should be dates. But now someone—a frontend team, a client, your future self who forgot everything—asks for the API documentation. You’re not going to hand-write an OpenAPI spec, are you? That would be like building a perfect, self-documenting castle and then painting the blueprints by hand on the outside wall. We automate this.

The goal is simple: we want our single source of truth—our TypeScript code—to generate the OpenAPI specification. This keeps everything in sync. Change a route? The types and the docs update together. It’s the dream, and it’s absolutely achievable, though sometimes the path is a bit… rustic.

The Core Concept: Metadata is Everything

These tools don’t magically read your mind. They read metadata. You have to decorate your routes with information that isn’t already present in the TypeScript type system. Your function might expect an id: number, but how is the generator supposed to know that this id comes from the route path (/user/:id) and not a query parameter or a JSON body? It can’t. So we tell it.

This is the fundamental trade-off. You get a gorgeous, accurate spec, but you have to give a little more upfront. The good news is that if you’re using Fastify, this philosophy is baked into its DNA. Express requires a bit more… persuasion.

The Fastify Way: It’s Baked In

Fastify is built with this in mind. Its schema-based validation (using JSON Schema) is the very thing that powers its excellent performance and its OpenAPI integration. Here’s a typical Fastify route. Notice how we’re not just defining types; we’re defining a schema.

import { Type, type Static } from '@sinclair/typebox';

// Define a TypeBox schema (which is JSON Schema)
const UserBody = Type.Object({
  name: Type.String({ minLength: 1 }),
  email: Type.String({ format: 'email' }),
  isActive: Type.Optional(Type.Boolean({ default: true }))
});
type UserBody = Static<typeof UserBody>; // Create a TS type from it

const UserParams = Type.Object({
  userId: Type.Integer()
});
type UserParams = Static<typeof UserParams>;

// Now use them in a route
server.post<{
  Body: UserBody,
  Params: UserParams
}>('/user/:userId', {
  // This schema object is what Fastify uses for validation AND OpenAPI generation
  schema: {
    body: UserBody,
    params: UserParams,
    response: {
      201: Type.Object({ id: Type.Integer(), message: Type.String() }),
      400: Type.Object({ error: Type.String() })
    }
  }
}, async (request, reply) => {
  // Your business logic here. request.body and request.params are fully typed.
  const newUser = await createUser(request.body);
  reply.code(201).send({ id: newUser.id, message: "User created" });
});

To generate the spec, you use a plugin like @fastify/swagger. Once configured, you can simply call server.swagger() to get the complete OpenAPI JSON. The magic is that the route schema object is already a valid JSON Schema, so the translation is seamless. It’s elegant, honest work.

The Express Way: TSOA and The Gang

Express doesn’t have schemas built in, so we need a different approach. This is where libraries like tsoa come in. They use a slightly more declarative method: you define your models and routes with extensive decorators, and then a CLI tool scans your code to generate the spec.

It feels more like a separate layer on top of Express, which it is. Here’s a taste:

// models/user.ts
import { IsEmail, IsInt, IsString } from 'class-validator';

export class User {
  @IsInt()
  id!: number;

  @IsString()
  name!: string;

  @IsEmail()
  email!: string;
}

// controllers/userController.ts
import { Route, Post, Body, Response, Path } from 'tsoa';

@Route('users')
export class UsersController {
  @Post()
  @Response<{ id: number; message: string }>(201, 'User Created')
  @Response<{ error: string }>(400, 'Bad Request')
  public async createUser(@Body() requestBody: User): Promise<{ id: number; message: string }> {
    // ... implementation
  }

  @Get('{userId}')
  public async getUser(@Path() userId: number): Promise<User> {
    // ... implementation
  }
}

You then run tsoa spec in your package.json scripts, and it generates the openapi.json file. The upside is incredible power and accuracy. The downside? You’re now married to tsoa’s way of doing things, and debugging decorators can sometimes feel like shouting into a void.

The Rough Edges and Pitfalls

Let’s be real, this isn’t always sunshine and rainbows.

  1. The Void of any and unknown: If you use any in your route definitions, the generator has absolutely no idea what to do. It might just give up and leave that part out of the spec. This is a feature, not a bug—it’s punishing you for your type-safety sins.
  2. Complex Generics and Utility Types: You might have a beautiful, DRY type like ApiResponse<T>. The generator might look at it and just see T. You’ll often need to help it out by using your library’s specific tools (like TypeBox for Fastify or the decorators for tsoa) instead of pure TypeScript types.
  3. It’s Another Thing to Configure: The setup isn’t trivial. You’ll spend time getting the right security scheme definitions, tagging your routes correctly, and making the generated UI look presentable. Don’t expect to just plug it in and be done in five minutes.
  4. The “Just Good Enough” Generator: Some scenarios are so complex that the generated spec is almost right, but needs a tiny manual tweak. Most tools allow you to extend the spec programmatically, but now you’ve got a hybrid system. Document where those manual overrides live, or you’ll create a nightmare for your teammates.

The best practice? Embrace your chosen library’s ecosystem fully. Don’t fight it. Use TypeBox with Fastify instead of raw TypeScript types. Use tsoa’s decorators religiously. The more you lean into their way of defining the world, the more flawless your output will be. It’s a bit of a gateway drug, but the high of having always-in-sync API docs is worth the price of admission.