Building a TypeScript REST API with Fastify v5 + TypeBox
Let's start with the code showing the structural problem that arises when writing an API with Express.
// Express approach: the same information is scattered across three places
interface CreateUserDto { // 1. TypeScript type
name: string
email: string
role: 'admin' | 'editor' | 'viewer'
}
app.post('/users',
body('email').isEmail(), // 2. Runtime validation
body('name').notEmpty(),
async (req, res) => {
const errors = validationResult(req)
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() })
// 3. Swagger docs are managed separately in JSDoc or a YAML file
const user = req.body as CreateUserDto
res.json(await createUser(user))
}
)The TypeScript type, runtime validation rules, and API documentation each live in a different place. If any one of the three changes, the others don't automatically follow. Fastify v5 + TypeBox unifies all three into a single schema.
Core Concepts
Why Fastify Is Fast
Express and Koa parse the body with JSON.parse() on every incoming request and call JSON.stringify() on every response. That cost accumulates in high-throughput environments.
Fastify compiles JSON Schemas at server startup. Ajv pre-generates optimized validation functions instead of interpreting schemas at runtime, and fast-json-stringify likewise pre-generates serialization functions. At runtime, only the already-compiled functions are executed.
In terms of throughput, the official benchmark (fastify.dev/benchmarks) shows more than twice the performance of Express. Fastify v5 supports only Node.js v20+, so it also takes full advantage of the latest V8 JIT optimizations.
TypeBox: Types and Schemas at Once
@sinclair/typebox declares TypeScript types and JSON Schemas simultaneously.
import { Type, Static } from '@sinclair/typebox'
const UserSchema = Type.Object({
id: Type.Number(),
name: Type.String({ minLength: 1 }),
email: Type.String({ format: 'email' })
})
// The type is inferred automatically without a separate interface declaration
type User = Static<typeof UserSchema>
// => { id: number; name: string; email: string }The return value of Type.Object() is both the type information the TypeScript compiler reads and the JSON Schema Fastify passes to Ajv. Because Fastify's internal pipeline directly consumes JSON Schemas, TypeBox connects natively without an additional adapter. That is why TypeBox is listed as a recommended library in the official Fastify documentation.
The Pipeline a Single Schema Creates
Here is how one TypeBox schema simultaneously serves multiple roles.
Calling .withTypeProvider<TypeBoxTypeProvider>() from @fastify/type-provider-typebox automatically infers request.body, request.params, and request.query in route handlers as the TypeScript types defined in the schema.
Practical Application
Initial Project Setup
npm install fastify @sinclair/typebox @fastify/type-provider-typebox
npm install @fastify/swagger @fastify/swagger-ui
npm install -D typescript @types/node tsxLet's look at the server entry point and app configuration in their completed form first.
// src/app.ts
import Fastify from 'fastify'
import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'
import swagger from '@fastify/swagger'
import swaggerUi from '@fastify/swagger-ui'
import usersRoute from './routes/users'
export async function buildApp() {
const app = Fastify({
logger: true
}).withTypeProvider<TypeBoxTypeProvider>()
await app.register(swagger, {
openapi: {
info: {
title: 'Users API',
version: '1.0.0'
}
}
})
await app.register(swaggerUi, {
routePrefix: '/docs'
})
await app.register(usersRoute, { prefix: '/api/v1' })
app.setErrorHandler((error, req, reply) => {
if (error.validation) {
return reply.code(400).send({
statusCode: 400,
error: 'Validation Error',
message: error.message,
details: error.validation
})
}
app.log.error(error)
return reply.code(500).send({
statusCode: 500,
error: 'Internal Server Error',
message: 'Something went wrong'
})
})
return app
}// src/server.ts
import { buildApp } from './app'
async function main() {
const app = await buildApp()
await app.listen({ port: 3000, host: '0.0.0.0' })
}
main()Schema Definition
Splitting schemas into a separate file makes them easy to reuse across multiple routes.
// src/schemas/user.ts
import { Type } from '@sinclair/typebox'
export const UserIdParams = Type.Object({
id: Type.Number({ minimum: 1 })
})
export const CreateUserBody = Type.Object({
name: Type.String({ minLength: 1, maxLength: 100 }),
email: Type.String({ format: 'email' }),
role: Type.Union([
Type.Literal('admin'),
Type.Literal('editor'),
Type.Literal('viewer')
])
})
export const UserResponse = Type.Object({
id: Type.Number(),
name: Type.String(),
email: Type.String(),
role: Type.String(),
createdAt: Type.String({ format: 'date-time' })
})
export const UserListQuery = Type.Object({
page: Type.Optional(Type.Number({ minimum: 1, default: 1 })),
limit: Type.Optional(Type.Number({ minimum: 1, maximum: 100, default: 20 }))
})The default values in UserListQuery are applied automatically without any additional configuration because Fastify's default Ajv setting has useDefaults: true. If page is absent from the query string, req.query.page will be 1.
Connecting Routes
// src/routes/users.ts
import { FastifyPluginAsyncTypebox } from '@fastify/type-provider-typebox'
import { Type } from '@sinclair/typebox'
import {
UserIdParams,
CreateUserBody,
UserResponse,
UserListQuery
} from '../schemas/user'
// The following is an in-memory stub to be replaced with a real DB layer
type UserRecord = { id: number; name: string; email: string; role: string; createdAt: string }
const db: UserRecord[] = []
let nextId = 1
async function fetchUsers(page: number, limit: number): Promise<UserRecord[]> {
return db.slice((page - 1) * limit, page * limit)
}
async function findUser(id: number): Promise<UserRecord | undefined> {
return db.find(u => u.id === id)
}
async function findUserByEmail(email: string): Promise<UserRecord | undefined> {
return db.find(u => u.email === email)
}
async function createUser(data: { name: string; email: string; role: string }): Promise<UserRecord> {
const user: UserRecord = { ...data, id: nextId++, createdAt: new Date().toISOString() }
db.push(user)
return user
}
const usersRoute: FastifyPluginAsyncTypebox = async (app) => {
// GET /users — list
app.get('/users', {
schema: {
querystring: UserListQuery,
response: {
200: Type.Array(UserResponse)
}
}
}, async (req) => {
// req.query.page and req.query.limit are both inferred as number | undefined
const { page = 1, limit = 20 } = req.query
return fetchUsers(page, limit)
})
// GET /users/:id — single item
app.get('/users/:id', {
schema: {
params: UserIdParams,
response: {
200: UserResponse,
404: Type.Object({ message: Type.String() })
}
}
}, async (req, reply) => {
// req.params.id is inferred as number
// Ajv converts the URL string "42" to the number 42 via the coerceTypes option
const user = await findUser(req.params.id)
if (!user) {
return reply.code(404).send({ message: 'User not found' })
}
return user
})
// POST /users — create
app.post('/users', {
schema: {
body: CreateUserBody,
response: {
201: UserResponse,
409: Type.Object({ message: Type.String() })
}
},
preHandler: async (req, reply) => {
// Async validation is handled in the preHandler hook
// Using Ajv $async directly carries a DoS risk
const existing = await findUserByEmail(req.body.email)
if (existing) {
return reply.code(409).send({ message: 'Email already exists' })
}
}
}, async (req, reply) => {
// req.body.name, req.body.email, and req.body.role are all fully type-inferred
const user = await createUser(req.body)
return reply.code(201).send(user)
})
}
export default usersRouteWhen the server starts, Swagger UI comes up automatically at http://localhost:3000/docs. The TypeBox schemas declared on routes are converted directly into an OpenAPI 3.x specification. Status codes not declared in the response schema are not reflected in the Swagger docs, so it is good practice to explicitly list in schema.response any status codes that can be returned from preHandler, such as 409.
Pros and Cons
Performance and Productivity Advantages
| Item | Fastify v5 | Express 5 | Koa |
|---|---|---|---|
| Throughput | 2× or more vs. Express (official benchmark) | Baseline | Higher than Express |
| Type safety | Compile-time & runtime simultaneously | Manual management | Manual management |
| Request validation | Built-in (Ajv) | Separate library | Separate library |
| OpenAPI docs | Schema-based automation | Annotations or manual | Manual |
| JSON serialization | fast-json-stringify | JSON.stringify | JSON.stringify |
Fields not in the response schema are automatically stripped by fast-json-stringify during serialization. This has the side effect of structurally preventing accidental exposure of sensitive fields.
Extracting TypeBox schemas into a shared package lets you maintain API contracts across multiple services from a single source of truth. When the schema changes, the auto-generated OpenAPI spec updates along with it, so the frontend team can always reference the latest spec without a separate sync meeting.
TypeBox vs Zod is a frequently asked question, and there is a clear reason to choose TypeBox for Fastify-based projects. Although Zod v4 significantly improved runtime performance over previous versions, Ajv converts schemas into optimized validation functions at compile time rather than interpreting them — a structural difference. For environments outside Fastify (NestJS, standalone validation layers, etc.), Zod's developer experience and rich error messages may be more appealing.
Practical Challenges When Migrating
Express → Fastify migration:
| Item | Details |
|---|---|
| Express middleware incompatibility | Connect/Express middleware cannot be used directly. A @fastify/express compatibility layer exists, but it eliminates a significant portion of the performance benefit |
| Plugin scope learning curve | It takes time to understand plugin registration order, the decorate API, and scope boundaries |
| Ecosystem size | The Express ecosystem is overwhelmingly larger, and the selection of Fastify third-party plugins is smaller |
Fastify v4 → v5 migration:
| Item | Details |
|---|---|
| Scale of breaking changes | 20+ breaking changes. In particular, the type provider separation affects type definitions in existing plugins |
| Official Migration Guide | Check each item at fastify.dev/docs/latest/Guides/Migration-Guide-V5/ |
TypeBox's own limitations:
Expressing complex unions/discriminated unions is more verbose compared to Zod. If your project has many complex domain models, it is worth comparing this aspect first.
Common Mistakes
1. Using the old variadic listen signature
// Does not work — signature removed in v5
app.listen(3000, '0.0.0.0', () => {
console.log('listening')
})
// Use the object form in v5
await app.listen({ port: 3000, host: '0.0.0.0' })2. Expecting type inference without withTypeProvider
// Without withTypeProvider, body is inferred as unknown
const app = Fastify()
// This is required for type inference in route handlers
const app = Fastify().withTypeProvider<TypeBoxTypeProvider>()3. Using a flat object schema for params
// Dangerous — validation is silently skipped with no error
schema: {
params: { id: { type: 'number' } }
}
// Correct — a JSON Schema with a type: 'object' + properties structure
schema: {
params: Type.Object({ id: Type.Number() })
}The first form is more dangerous because no error is thrown. When Ajv encounters a top-level id key declared without a type: 'object' + properties structure, it fails to recognize the field and passes any value through validation.
Closing Thoughts
The core of the Fastify v5 + TypeBox combination is the single source of truth principle: the schema is the contract. One TypeBox schema simultaneously produces TypeScript types, Ajv runtime validation, fast-json-stringify serialization, and OpenAPI documentation. This pipeline is compiled at server startup, minimizing runtime overhead.
Fastify v4 has already reached end-of-life, and v5 is the mainstream version. If you are considering a migration, the following order makes for a smooth start.
Step 1 — Start with one new endpoint: You don't have to migrate your entire existing Express project at once. Write one new feature in Fastify to get hands-on experience with the plugin system and TypeBox schema declaration.
Step 2 — Extract schemas into a package: Separating TypeBox schemas into a standalone package or directory lets you manage API contracts across multiple services from a single source of truth. When the schema changes, the auto-generated OpenAPI spec and the frontend team's type generation pipeline update together.
Step 3 — Extend with official plugins: @fastify/jwt, @fastify/rate-limit, and @fastify/cors integrate naturally with Fastify's scope system. The pattern of applying the JWT plugin only to the route group that requires authentication works especially cleanly in scope units.
References
- Fastify Official Docs v5 — Validation and Serialization
- Fastify Official Docs v5 — Type Providers
- Fastify Official Docs v5 — TypeScript Reference
- Fastify v5 Migration Guide
- Fastify Performance Benchmarks
- Fastify v5 is Here! — Platformatic Blog
- Fastify v5 Breaking Changes: Worth the Upgrade? — Encore Blog
- fastify/fastify-swagger — GitHub
- Fastify JSON API: Schema Validation, Serialization, and TypeBox — Jsonic
- TypeBox vs Zod: Choosing the Right TypeScript Validation Library — Better Stack
- Fastify Fundamentals: How to Validate API Responses — Platformatic Blog
- Migrating from Express to Fastify: A Complete Guide — Better Stack
- How to Generate OpenAPI with Fastify — Speakeasy
- Zod vs Yup vs TypeBox: The Ultimate Schema Validation Guide for 2025 — DEV Community