Node.js Backend: Fastify + TypeBox Route-Level Type Inference
The Express Type Problem
Express is the most popular Node.js framework, but its type design has a fundamental flaw: the request body is any.
// Express route โ the type system is decorative
app.post('/users', (req, res) => {
const name = req.body.name; // type: any
const age = req.body.age; // type: any
const typo = req.body.naem; // type: any โ typo, compiler says nothing
res.json({ id: '123' });
});
req.body defaults to any, meaning you can access any non-existent property and the compiler stays silent. Even adding generics manually is cosmetic:
// Express generics: manual declaration, easy to drift
interface CreateUserBody {
name: string;
age: number;
}
app.post<{}, {}, CreateUserBody>('/users', (req, res) => {
const name = req.body.name; // now string โ but this is your assertion, not inference
// Express does NOT validate that runtime data actually matches this type
// If the client sends { name: 123 }, TypeScript won't catch it
});
The root problem: types and validation logic are separate. Types exist at compile time; validation happens at runtime. They operate independently โ a bug in either one does not affect the other.
The correct Express approach involves zod or joi for runtime validation, then type assertions to inform the compiler. This works but requires boilerplate, and the two systems (schema + type) must be kept in sync manually.
Fastify + TypeBox: Schema Is the Type
Fastify treats JSON Schema as a first-class citizen from the beginning. TypeBox is a library that lets you write objects that simultaneously satisfy JSON Schema spec and carry TypeScript type information โ one definition, two uses.
Installation
npm install fastify @fastify/type-provider-typebox @sinclair/typebox
TypeBox basics: building schemas
import { Type, Static } from '@sinclair/typebox';
// Primitive types
const NameSchema = Type.String({ minLength: 1, maxLength: 100 });
const AgeSchema = Type.Number({ minimum: 0, maximum: 150 });
// Object schema
const CreateUserBodySchema = Type.Object({
name: Type.String({ minLength: 1 }),
age: Type.Number({ minimum: 0 }),
email: Type.Optional(Type.String({ format: 'email' })),
});
// Extract the TypeScript type from the schema
type CreateUserBody = Static<typeof CreateUserBodySchema>;
// Equivalent to: { name: string; age: number; email?: string }
// The JSON Schema that TypeBox generates โ Fastify uses this for runtime validation
console.log(JSON.stringify(CreateUserBodySchema));
// {"type":"object","properties":{"name":{"type":"string","minLength":1},...},"required":["name","age"]}
Type.Object() returns an object that is both a valid JSON Schema (passable to any JSON Schema validator) and carries TypeScript type information (extracted via Static<>).
TypeBox building blocks
import { Type } from '@sinclair/typebox';
// Primitives
Type.String() // string
Type.Number() // number
Type.Boolean() // boolean
Type.Null() // null
Type.Literal('admin') // 'admin'
// Modifiers
Type.Optional(Type.String()) // string | undefined (in object properties)
Type.Readonly(Type.String()) // readonly string
// Composite types
Type.Array(Type.String()) // string[]
Type.Tuple([Type.String(), Type.Number()]) // [string, number]
Type.Union([Type.String(), Type.Number()]) // string | number
Type.Intersect([SchemaA, SchemaB]) // A & B
// Objects
Type.Object({ id: Type.String(), count: Type.Number() })
// Enums
Type.Enum({ Active: 'active', Inactive: 'inactive' })
// Constrained strings
Type.String({ minLength: 1, maxLength: 255, pattern: '^[a-z]+$' })
// Ranged numbers
Type.Number({ minimum: 0, maximum: 100, multipleOf: 1 })
Route-Level Full Type Inference
import Fastify from 'fastify';
import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox';
import { Type } from '@sinclair/typebox';
const fastify = Fastify().withTypeProvider<TypeBoxTypeProvider>();
// Define the route schema
const CreateUserSchema = {
body: Type.Object({
name: Type.String({ minLength: 1 }),
age: Type.Number({ minimum: 0 }),
role: Type.Optional(Type.Union([
Type.Literal('admin'),
Type.Literal('user'),
])),
}),
response: {
201: Type.Object({
id: Type.String(),
name: Type.String(),
createdAt: Type.String(),
}),
400: Type.Object({
error: Type.String(),
message: Type.String(),
}),
},
} as const;
fastify.post('/users', { schema: CreateUserSchema }, async (req, reply) => {
// req.body type is fully inferred:
// { name: string; age: number; role?: 'admin' | 'user' }
const { name, age, role } = req.body;
// name is string โ the compiler knows
const upperName = name.toUpperCase();
// role is 'admin' | 'user' | undefined โ union narrowing works correctly
if (role === 'admin') {
// role narrowed to 'admin' here
}
// reply.code(201).send() argument type matches the response[201] schema
return reply.code(201).send({
id: crypto.randomUUID(),
name,
createdAt: new Date().toISOString(),
});
});
The key: .withTypeProvider<TypeBoxTypeProvider>() tells Fastify which type provider to use. After that, each route's schema field automatically infers the types for req.body, req.params, and req.query.
Route params and query strings
const GetUserSchema = {
params: Type.Object({
id: Type.String({ pattern: '^[0-9a-f-]{36}$' }),
}),
querystring: Type.Object({
include: Type.Optional(Type.Array(Type.String())),
format: Type.Optional(Type.Union([
Type.Literal('json'),
Type.Literal('csv'),
])),
}),
response: {
200: Type.Object({
id: Type.String(),
name: Type.String(),
age: Type.Number(),
}),
404: Type.Object({ error: Type.String() }),
},
};
fastify.get('/users/:id', { schema: GetUserSchema }, async (req, reply) => {
const { id } = req.params; // string
const { include, format } = req.query; // string[] | undefined, 'json' | 'csv' | undefined
const user = await db.users.findById(id);
if (!user) {
return reply.code(404).send({ error: 'User not found' });
}
return reply.send(user);
});
Typed Middleware: Augmenting Request/Reply
Fastify's plugin system lets you attach data to the request object. Via declaration merging, types stay synchronized.
// auth-plugin.ts
import { FastifyPluginAsync, FastifyRequest } from 'fastify';
import fp from 'fastify-plugin';
// Augment Fastify's type declarations
declare module 'fastify' {
interface FastifyRequest {
user: {
id: string;
role: 'admin' | 'user';
email: string;
} | null;
}
}
const authPlugin: FastifyPluginAsync = async (fastify) => {
fastify.addHook('preHandler', async (request, reply) => {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
request.user = null;
return;
}
try {
request.user = await verifyToken(token);
} catch {
reply.code(401).send({ error: 'Invalid token' });
}
});
};
export default fp(authPlugin);
// Using it in a route
fastify.get('/profile', {
preHandler: [requireAuth],
schema: { response: { 200: UserProfileSchema } }
}, async (req, reply) => {
// req.user type: { id: string; role: 'admin' | 'user'; email: string } | null
if (!req.user) {
return reply.code(401).send({ error: 'Unauthorized' });
}
// req.user narrowed to non-null here
const profile = await db.profiles.findByUserId(req.user.id);
return reply.send(profile);
});
Typed Error Handling
Fastify's error handler hook is also fully typeable:
import { FastifyError } from 'fastify';
class AppError extends Error {
constructor(
public readonly statusCode: number,
public readonly code: string,
message: string,
) {
super(message);
this.name = 'AppError';
}
}
fastify.setErrorHandler((error: FastifyError | AppError, request, reply) => {
if (error instanceof AppError) {
return reply.code(error.statusCode).send({
code: error.code,
message: error.message,
});
}
// Fastify's validation errors
if (error.validation) {
return reply.code(400).send({
code: 'VALIDATION_ERROR',
message: 'Request validation failed',
details: error.validation,
});
}
fastify.log.error(error);
return reply.code(500).send({
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
});
});
Fastify automatically throws a structured error when schema validation fails โ you do not need to manually check types in each route handler.
Prisma Integration: Type-Safe Database Queries
Prisma auto-generates types for every model, and it pairs naturally with Fastify + TypeBox.
// schema.prisma
model User {
id String @id @default(uuid())
name String
age Int
email String? @unique
role Role @default(USER)
posts Post[]
createdAt DateTime @default(now())
}
enum Role {
ADMIN
USER
}
import { PrismaClient } from '@prisma/client';
import { Type, Static } from '@sinclair/typebox';
const prisma = new PrismaClient();
const CreateUserBodySchema = Type.Object({
name: Type.String({ minLength: 1 }),
age: Type.Number({ minimum: 0, maximum: 150 }),
email: Type.Optional(Type.String({ format: 'email' })),
});
fastify.post('/users', {
schema: { body: CreateUserBodySchema, response: { 201: UserResponseSchema } }
}, async (req, reply) => {
const { name, age, email } = req.body;
// prisma.user.create argument types are inferred by Prisma
// Passing a field that doesn't exist in the schema causes a compile error
const user = await prisma.user.create({
data: { name, age, email },
select: {
id: true,
name: true,
createdAt: true,
},
});
// user type: { id: string; name: string; createdAt: Date }
// select changes the return type โ TypeScript infers it automatically
return reply.code(201).send({
...user,
createdAt: user.createdAt.toISOString(),
});
});
How select and include affect the return type
// Different queries, different types
const userBasic = await prisma.user.findUnique({
where: { id },
select: { id: true, name: true },
});
// type: { id: string; name: string } | null
const userWithPosts = await prisma.user.findUnique({
where: { id },
include: { posts: true },
});
// type: (User & { posts: Post[] }) | null
// Use Prisma.UserGetPayload to extract the type for complex queries
import { Prisma } from '@prisma/client';
type UserWithPosts = Prisma.UserGetPayload<{
include: { posts: true };
}>;
// equivalent to User & { posts: Post[] }
Complete CRUD Example
import Fastify from 'fastify';
import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox';
import { Type } from '@sinclair/typebox';
import { PrismaClient } from '@prisma/client';
const fastify = Fastify({ logger: true }).withTypeProvider<TypeBoxTypeProvider>();
const prisma = new PrismaClient();
const UserIdParam = Type.Object({ id: Type.String() });
const UserBody = Type.Object({
name: Type.String({ minLength: 1 }),
age: Type.Number({ minimum: 0 }),
email: Type.Optional(Type.String({ format: 'email' })),
});
const UserResponse = Type.Object({
id: Type.String(),
name: Type.String(),
age: Type.Number(),
email: Type.Union([Type.String(), Type.Null()]),
createdAt: Type.String(),
});
// GET /users
fastify.get('/users', {
schema: { response: { 200: Type.Array(UserResponse) } }
}, async (req, reply) => {
const users = await prisma.user.findMany();
return users.map(u => ({ ...u, createdAt: u.createdAt.toISOString() }));
});
// POST /users
fastify.post('/users', {
schema: { body: UserBody, response: { 201: UserResponse } }
}, async (req, reply) => {
const user = await prisma.user.create({ data: req.body });
return reply.code(201).send({ ...user, createdAt: user.createdAt.toISOString() });
});
// GET /users/:id
fastify.get('/users/:id', {
schema: {
params: UserIdParam,
response: { 200: UserResponse, 404: Type.Object({ error: Type.String() }) }
}
}, async (req, reply) => {
const user = await prisma.user.findUnique({ where: { id: req.params.id } });
if (!user) return reply.code(404).send({ error: 'Not found' });
return { ...user, createdAt: user.createdAt.toISOString() };
});
// PATCH /users/:id
fastify.patch('/users/:id', {
schema: {
params: UserIdParam,
body: Type.Partial(UserBody), // all fields become optional
response: { 200: UserResponse },
}
}, async (req, reply) => {
const user = await prisma.user.update({
where: { id: req.params.id },
data: req.body,
});
return { ...user, createdAt: user.createdAt.toISOString() };
});
// DELETE /users/:id
fastify.delete('/users/:id', {
schema: { params: UserIdParam, response: { 204: Type.Null() } }
}, async (req, reply) => {
await prisma.user.delete({ where: { id: req.params.id } });
return reply.code(204).send();
});
Anti-Patterns
| Anti-pattern | Problem | Correct approach |
|---|---|---|
(req.body as CreateUserBody) |
Bypasses validation; the type is a lie | Use TypeBox Schema + TypeBoxTypeProvider |
| Manual validation outside the route, then assert | Validation logic duplicated, drifts from schema | Let Fastify + TypeBox handle runtime validation |
Type.Any() or Type.Unknown() for the whole body |
Abandons type safety | Define each field specifically |
Forgetting .withTypeProvider<TypeBoxTypeProvider>() |
Type inference breaks; body stays unknown |
Add it immediately when initializing Fastify |
Returning Prisma Date objects directly |
Date does not serialize correctly with JSON.stringify |
Convert to .toISOString() strings |
Summary
| Capability | Express | Fastify + TypeBox |
|---|---|---|
req.body type |
any (default) |
Auto-inferred from schema |
| Runtime validation | Manual (requires zod/joi) | Built-in (JSON Schema) |
| Schema/type sync | Two systems to maintain manually | One definition, automatically in sync |
| Response type checking | None | reply.send() argument is typed |
| Params / query strings | any |
Inferred from schema |
| Performance | Baseline | 2โ10ร faster than Express (Fastify's design goal) |
Next chapter moves to the frontend: how to type React components correctly โ why React.FC is discouraged, how to write generic components, and the type inference details behind hooks.