Typing Express/Fastify Handlers
Type-safe HTTP handlers catch bad body, params, and query shapes at compile time - pair Express 5 generics and Fastify 5 schema inference with runtime validation at boundaries.
Recipe
// Express 5 - typed body via generic (after validation middleware)
import type { Request, Response } from 'express';
interface CreateUserBody { email: string; name: string }
export async function createUser(
req: Request<unknown, unknown, CreateUserBody>,
res: Response,
): Promise<void> {
res.status(201).json({ id: '1', ...req.body });
}// Fastify 5 - schema-driven types
import Fastify from 'fastify';
const app = Fastify();
app.post<{ Body: { email: string } }>('/users', async (req, reply) => {
return reply.code(201).send({ id: '1', email: req.body.email });
});When to reach for this:
- CRUD APIs with repeated param shapes (
id, pagination) - Auth middleware attaching
userIdto requests - Shared handler utilities across routers
- Preventing
req.body.footypos before runtime
Working Example
// express-app.d.ts - augmentation
import 'express-serve-static-core';
declare module 'express-serve-static-core' {
interface Request {
userId?: string;
}
}// auth-middleware.ts
import type { Request, Response, NextFunction } from 'express';
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
res.status(401).json({ error: 'unauthorized' });
return;
}
req.userId = 'user-123';
next();
}// fastify-route.ts
import Fastify from 'fastify';
import { z } from 'zod';
const app = Fastify();
const CreateUser = z.object({ email: z.string().email(), name: z.string().min(1) });
app.post('/users', {
schema: {
body: {
type: 'object',
required: ['email', 'name'],
properties: { email: { type: 'string' }, name: { type: 'string' } },
},
},
}, async (req, reply) => {
const body = CreateUser.parse(req.body);
return reply.code(201).send({ id: '1', ...body });
});What this demonstrates:
- Express augmentation adds
userIdwith proper typing across handlers - Middleware should call
next()or end response - typedNextFunctionfor errors - Fastify JSON Schema validates at runtime; Zod adds stricter rules (email format)
- Parse after schema validation for complex rules Express/Fastify schemas cannot express
Deep Dive
How It Works
- Express uses
Request<P, ResBody, ReqBody, ReqQuery, Locals>generics - rarely all filled manually; augmentation for cross-cutting fields. - Fastify infers types from
schemawhen usingas constor TypeBox - Zod bridge viafastify-type-provider-zod. - Response typing -
res.json<T>()is not enforced at runtime - still validate outbound DTOs for public APIs. - Error middleware - Express
(err, req, res, next); FastifysetErrorHandlerwith typed errors.
Handler Typing Patterns
| Framework | Request typing | Validation |
|---|---|---|
| Express 5 | Generics + augmentation | Zod middleware manual |
| Fastify 5 | Schema inference | JSON Schema + Zod parse |
| NestJS 11 | DTO classes + pipes | class-validator / Zod pipe |
TypeScript Notes
import type { FastifyRequest, FastifyReply } from 'fastify';
type AuthedRequest = FastifyRequest & { userId: string };
function getUserId(req: FastifyRequest): string {
return (req as AuthedRequest).userId;
}Prefer plugins/decorators (fastify.decorateRequest) over casts when possible.
Gotchas
- Trusting
req.bodywithout validation - types lie at runtime. Fix: Zod parse at boundary per Zod at Boundaries. - Over-generic Request everywhere -
anyleaks via defaults. Fix: per-route handler types or sharedAppRequestalias. - Mismatch Express types version -
@types/expressmust match Express 5. Fix: align versions inpackage.json. - Fastify schema and Zod drift - two sources of truth. Fix: generate JSON Schema from Zod or use type provider.
- Async middleware typing - returning rejected Promise without
next(err)in Express. Fix: wrap async middleware utilities.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Zod + manual Express types | Full control of validation | You want schema perf of Fastify |
@fastify/type-provider-zod | Single Zod source in Fastify | Express-only stack |
| NestJS DTOs | Large teams, decorators OK | Minimal HTTP microservice |
| OpenAPI codegen | Contract-first public APIs | Internal-only CRUD |
FAQs
Does Express 5 improve async typing?
Better async error propagation - still validate bodies explicitly and type augmentations for locals.
How do I type req.params.id?
Request<{ id: string }> for route /users/:id - validate UUID format with Zod.
What is Locals generic?
res.locals typing in Express - Request<..., Locals> for template middleware data.
Does Fastify infer reply types?
Return type of handler influences serialization typing when schema response is defined.
How do I type WebSocket upgrades?
Separate from HTTP handlers - use ws types or Fastify @fastify/websocket plugin typings.
Can I share types between Express and Fastify?
Share DTO interfaces in @acme/types - framework wiring stays separate per app.
What about NestJS 11 controllers?
Use DTO classes with ValidationPipe - different pattern, same boundary validation principle.
Should res.json be generic?
res.json<UserDto>(dto) documents intent - does not validate serialization at runtime.
How do I type error handlers?
Express: Error or custom AppError with statusCode. Fastify: setErrorHandler infers reply shape.
Are @types/node and express separate?
Yes - install both; Node types do not include framework request fields.
What is declaration merging?
declare module 'express-serve-static-core' { interface Request { ... } } extends Express types project-wide.
How do query strings type?
Request<..., ..., ..., { page?: string }> - coerce with Zod z.coerce.number() for page.
Related
- Zod at Boundaries - runtime validation
- Express Basics - Express 5 patterns
- Fastify Basics - Fastify 5 setup
- Sharing Types with Frontend - DTO packages
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.