Learn how to set up an Express 5 project with TypeScript, tsx and @types/express, type requests and responses, validate bodies with Zod, and add custom properties to req.

Bishal Biswas
Self Learner
What you will learn
- What TypeScript adds to an Express project and why many teams use it
- How to set up a project with
typescript,tsx,@types/expressand atsconfig.json - How to type route handlers with
RequestandResponse, including the four generics - How to combine Zod validation with TypeScript types so you write each shape once
- How to type middleware and error handlers, and add your own properties such as
req.user
Why TypeScript?
JavaScript lets you write res.json(user.nmae) and only finds the typo when a user hits that route. TypeScript is JavaScript plus types: labels that say what shape each value has. A program called the compiler checks those labels before your code runs and points out mistakes in your editor.
Think of a spell checker. It does not change what you mean, but it underlines mistakes while you type, long before anyone reads your letter. TypeScript underlines user.nmae and suggests user.name.
| JavaScript | TypeScript | |
|---|---|---|
| Finds typos in property names | When the code runs | While you type |
Editor autocomplete for req., res. and your data |
Partial | Full |
| Refactoring (renaming a field everywhere) | Search and hope | Safe, the compiler finds every use |
| Extra setup | None | A compiler, a config file, type packages |
Types disappear when TypeScript is turned into JavaScript. They cost nothing at runtime, and they do not check data coming from users. You still need validation, as you will see below.
Setting up the project
mkdir notes-ts && cd notes-ts
npm init -y
npm install express zod
npm install --save-dev typescript tsx @types/express @types/node
| Package | Why |
|---|---|
typescript |
The compiler (tsc) |
tsx |
Runs .ts files directly in development, with watch mode |
@types/express |
Type descriptions for Express. Version 5 matches Express 5 |
@types/node |
Type descriptions for Node.js built-ins such as process |
Note: Express itself is written in JavaScript. The
@types/expresspackage is a separate, community-maintained description of its API. Make sure its major version matches Express: Express 5 needs@types/express5.
package.json
{
"name": "notes-ts",
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"typecheck": "tsc --noEmit"
}
}
tsconfig.json
This file tells the compiler how strict to be and where files live.
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"sourceMap": true
},
"include": ["src"]
}
| Option | Meaning |
|---|---|
module / moduleResolution: "NodeNext" |
Follow Node.js’s real ES module rules |
strict: true |
Turn on all the useful checks. Always use this for new projects |
esModuleInterop |
Lets you write import express from 'express' for packages written in CommonJS |
skipLibCheck |
Do not type-check every file inside node_modules (faster) |
outDir / rootDir |
Compile src/*.ts into dist/*.js |
Your first typed server
// src/app.ts
import express from 'express';
import { notesRouter } from './routes/notes.js';
import { errorHandler } from './middleware/error-handler.js';
export const app = express();
app.use(express.json());
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
app.use('/notes', notesRouter);
app.use(errorHandler);
// src/server.ts
import { app } from './app.js';
const PORT = Number(process.env.PORT ?? 3000);
app.listen(PORT, (error) => {
if (error) throw error;
console.log(`Server running on http://localhost:${PORT}`);
});
Common mistake: Notice the imports end in
.js, even though the files are.ts. With"module": "NodeNext", you write the path of the file that will exist after compiling. Writing./appor./app.tsgives an error.
Run it in development:
npm run dev
tsx runs your TypeScript straight away and restarts on changes. It does not type-check, to stay fast. Your editor shows type errors as you work, and npm run typecheck checks the whole project (run it before committing and in CI).
For production, compile and run the plain JavaScript:
npm run build
npm start
Note: Recent Node.js versions can also run some TypeScript files directly by stripping out the types.
tsxis still the simplest choice for now because it supports more TypeScript features and has a built-in watch mode.
How Express types work
Hover over req in your editor inside app.get('/health', (req, res) => ...). TypeScript already knows it is a Request and res is a Response, because Express’s types describe what app.get passes to your function. You get autocomplete for req.params, res.status() and everything else for free.
When you write a handler in a separate place, or want to be more specific, you import the types:
// src/routes/example.ts
import type { Request, Response, NextFunction } from 'express';
export const hello = (req: Request, res: Response) => {
res.json({ message: `Hello from ${req.path}` });
};
import type imports only the type, which disappears after compiling.
The four generics of Request
Request and Response accept generics: extra type information in angle brackets, like filling in blanks on a form. Request has four, in this order:
Request< Params, ResBody, ReqBody, Query >
| | | |
| | | +-- req.query
| | +---------- req.body
| +------------------- what res.json() sends
+---------------------------- req.params
Response has one important generic: the body type you send.
// src/routes/notes.ts
import { Router, type Request, type Response } from 'express';
type Note = { id: string; title: string; createdAt: string };
type ErrorBody = { error: string };
const notes = new Map<string, Note>();
export const notesRouter = Router();
notesRouter.get('/:id', (req: Request<{ id: string }>, res: Response<Note | ErrorBody>) => {
const note = notes.get(req.params.id); // req.params.id is a string
if (!note) {
res.status(404).json({ error: 'Note not found' });
return;
}
res.json(note); // res.json({ wrong: true }) would be a type error here
});
Try changing res.json(note) to res.json({ titel: 'x' }). Your editor underlines it immediately.
Tip: Sending a response does not stop your function. After an early
res.status(404).json(...), addreturn;(as above) so the code below it does not try to send a second response. The Express 5 types also accept the shorterreturn res.status(404).json(...), but the separatereturn;makes it clearer that the handler’s return value is not used. Some older versions of@types/expressrejected the short form with a confusing “No overload matches this call” error, which is why you will see both styles in tutorials.
Typing request bodies with Zod
Here is the most important idea in this lesson: types do not check incoming data. If you write Request<{}, any, { title: string }>, TypeScript believes req.body.title is a string, but a client can still send { "title": 42 } or no body at all. Types are a promise to the compiler, not a guard at the door.
The fix is the validation you learned in Request Validation with Zod. Zod checks the data at runtime and produces a TypeScript type, so you describe each shape once.
// src/schemas/note.ts
import { z } from 'zod';
export const createNoteSchema = z.object({
title: z.string().trim().min(1, 'title is required'),
tags: z.array(z.string()).default([]),
});
// A TypeScript type generated from the schema: { title: string; tags: string[] }
export type CreateNoteInput = z.infer<typeof createNoteSchema>;
export const listNotesQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
// src/routes/notes.ts (continued)
import { randomUUID } from 'node:crypto';
import { createNoteSchema, listNotesQuerySchema, type CreateNoteInput } from '../schemas/note.js';
notesRouter.post('/', async (req, res: Response<Note | { errors: string[] }>) => {
const result = createNoteSchema.safeParse(req.body); // req.body is untrusted
if (!result.success) {
res.status(400).json({ errors: result.error.issues.map((issue) => issue.message) });
return;
}
const input: CreateNoteInput = result.data; // fully typed from here on
const note: Note = { id: randomUUID(), title: input.title, createdAt: new Date().toISOString() };
notes.set(note.id, note);
res.status(201).json(note);
});
notesRouter.get('/', (req, res: Response<Note[] | { errors: string[] }>) => {
const result = listNotesQuerySchema.safeParse(req.query);
if (!result.success) {
res.status(400).json({ errors: result.error.issues.map((issue) => issue.message) });
return;
}
const { page, limit } = result.data; // numbers, not strings
const all = [...notes.values()];
res.json(all.slice((page - 1) * limit, page * limit));
});
Inside the if (!result.success) check, TypeScript knows result.data does not exist. After it, TypeScript knows result.data matches the schema. This is called narrowing: the compiler follows your if checks.
Note: In Express 5,
req.queryis read-only, so do not try to replace it with the parsed version. Keep the validated values in a variable, as above.
Typing middleware and error handlers
Express exports ready-made types for common function shapes:
| Type | Use it for |
|---|---|
RequestHandler |
Normal middleware and route handlers (req, res, next) |
ErrorRequestHandler |
Error-handling middleware (err, req, res, next) |
// src/middleware/request-timer.ts
import type { RequestHandler } from 'express';
export const requestTimer: RequestHandler = (req, res, next) => {
const start = Date.now();
res.on('finish', () => {
console.log(`${req.method} ${req.originalUrl} ${res.statusCode} ${Date.now() - start}ms`);
});
next();
};
// src/middleware/error-handler.ts
import type { ErrorRequestHandler } from 'express';
export class HttpError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
}
}
export const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
if (res.headersSent) {
next(err);
return;
}
const status = err instanceof HttpError ? err.status : 500;
const message = status === 500 ? 'Internal server error' : err.message;
res.status(status).json({ error: message });
};
Because the variable is typed as ErrorRequestHandler, TypeScript fills in the types of all four parameters for you.
Adding your own properties to req
Your authentication middleware from JWT Authentication sets req.user. TypeScript does not know about that property, so req.user is an error everywhere.
You fix this with declaration merging: you add a property to Express’s own Request type. Create a type declaration file:
// src/types/express.d.ts
declare global {
namespace Express {
interface Request {
user?: {
id: string;
role: 'user' | 'admin';
};
}
}
}
export {}; // makes this file a module, which "declare global" needs
Because tsconfig.json includes the whole src folder, this file is picked up automatically. Now req.user works everywhere:
// src/middleware/require-auth.ts
import type { RequestHandler } from 'express';
import jwt from 'jsonwebtoken';
type TokenPayload = { sub: string; role: 'user' | 'admin' };
export const requireAuth: RequestHandler = (req, res, next) => {
const token = req.get('Authorization')?.replace('Bearer ', '');
if (!token) {
res.status(401).json({ error: 'Missing token' });
return;
}
try {
const payload = jwt.verify(token, process.env.JWT_SECRET ?? 'change-me') as TokenPayload;
req.user = { id: payload.sub, role: payload.role }; // no type error now
next();
} catch {
res.status(401).json({ error: 'Invalid token' });
}
};
This example also needs the JWT library and its types: npm install jsonwebtoken and npm install --save-dev @types/jsonwebtoken.
Warning:
as TokenPayloadis a type assertion: you are telling TypeScript “trust me”. It does not check anything. That is acceptable here becausejwt.verifyalready checked the signature, but avoidason data from users. Validate with Zod instead.
Async handlers
Express 5 forwards errors from async handlers to your error handler, and the Express 5 types accept handlers that return a Promise. So async routes need no wrapper:
// src/routes/users.ts
import { Router } from 'express';
import { HttpError } from '../middleware/error-handler.js';
import { findUser } from '../services/users.js';
export const usersRouter = Router();
usersRouter.get('/:id', async (req, res) => {
const user = await findUser(req.params.id); // if this throws, the error handler runs
if (!user) throw new HttpError(404, 'User not found');
res.json(user);
});
Common mistakes
- Mismatched type versions.
@types/express4 with Express 5 gives wrong types. Keep both on version 5. - Missing
.jsin relative imports withNodeNext. Write'./app.js'. - Trusting
req.bodytypes without validation. Types are erased at runtime; use Zod. - Using
anyeverywhere to silence errors. Eachanyswitches off checking for that value. Preferunknownand narrow it. - Only running
tsx. It does not type-check. Runtsc --noEmitin your test script or CI.
Key takeaways
- TypeScript catches mistakes before your code runs and gives full autocomplete; types cost nothing at runtime.
- Set up with
typescript,tsx(dev),@types/express5 and@types/node, plus aNodeNext+stricttsconfig.json. Request<Params, ResBody, ReqBody, Query>andResponse<Body>describe your routes;RequestHandlerandErrorRequestHandlertype middleware.- Types do not validate input. Use Zod’s
safeParseandz.inferto get both runtime checks and types from one schema. - Add custom properties like
req.userwith adeclare global { namespace Express { interface Request ... } }file.
Practice
- Convert one route file from your Notes API to TypeScript and fix every error
tsc --noEmitreports. - Write a Zod schema for updating a note (all fields optional) and use
z.inferto type the update function. - Add
requestId: stringtoRequestwith declaration merging, set it in a middleware withrandomUUID(), and include it in error responses. - Make
res.json()in one route typed withResponse<...>, then try sending a wrong shape and read the compiler’s message.
What’s next
You have now seen Express 5 from every angle. Next comes a single page that lists every change from Express 4, for when you meet older code. Continue with Express 5 Breaking Changes Reference.
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready