Stop 07 · TypeScript Across the StackTopics · 7/7

TypeScript with Node/Express

Part of the Full Stack Developer Roadmap

In short

On the server, wrong data doesn't just look ugly, it corrupts your database. Typing Express handlers, request bodies and responses brings the same safety you have in React to the backend.

Typing your Express route handlers, request/response objects and middleware — bringing the same safety to your backend that you get on the frontend.

The mascot fills a scooter with the correct green fuel nozzle at a pump.

Think of it like this

Petrol and diesel nozzles

At the pump, the diesel and petrol nozzles look different and are kept apart, so nobody fills a scooter with diesel. Types do that on your server: a request body or response of the wrong shape is caught before it reaches the engine.

Watch it happen

A typed POST /api/tasks

Request
Zod parse
Typed data
Handler
Typed reply

1/5req.body arrives as plain JSON. TypeScript can't know what's inside, so treat it as unknown.

The big ideas

What you need to know

Install the types

Add @types/express and @types/node as dev dependencies. Express 5's types come from @types/express v5.

Response generics

Response<Task> types what res.json accepts. Request<Params, ResBody, ReqBody> exists too, but parse bodies rather than trusting them.

Extend Request for req.user

Declare a user property on Express's Request interface once, so auth middleware and handlers agree on its type.

Run it simply

Use tsx in development, and tsc to build for production. Recent Node versions can also strip types and run .ts directly.

See it in code

Walk through the code

ts
1import express, { type Request, type Response, type NextFunction } from "express";2import { z } from "zod";3 4const NewTask = z.object({ title: z.string().min(1), due: z.coerce.date().optional() });5type Task = z.infer<typeof NewTask> & { id: number; done: boolean };6type ApiError = { error: string };7 8const app = express();9app.use(express.json());10app.post("/api/tasks", (req: Request, res: Response<Task | ApiError>) => {11  const parsed = NewTask.safeParse(req.body); // req.body is untrusted until parsed12  if (!parsed.success) return res.status(400).json({ error: "Title is required" });13  res.status(201).json({ id: Date.now(), done: false, ...parsed.data });14});15app.use((err: Error, req: Request, res: Response<ApiError>, next: NextFunction) => {16  res.status(500).json({ error: "Something broke on our side" }); });

1/4The schema defines valid input. The Task type is built from it plus server-added fields.

One schema gives you both runtime validation and the compile-time Task type.

Save yourself the pain

Common mistakes

Oops

Typing the body as Request<{}, {}, Task> and using req.body.title directly.

Do this instead

That only tells TS to trust the client. Parse req.body with a schema, then use the parsed result.

Oops

Using req.user everywhere with // @ts-ignore.

Do this instead

Add a declaration file that extends Express's Request type with user, once for the whole app.

Your mission

Type an existing API

~40 min

Quick check

You type a handler as Request<{}, {}, { title: string }>. A client sends { title: 42 }. What happens?

Go deeper

  1. 1Set up a Node + Express + TypeScript project from scratch.
  2. 2Type your route handlers' request body and response shapes using your shared types.
  3. 3Use `ts-node` or `tsx` for a fast local development loop.

More topics at this stop

Stuck on this topic? Ask an Insider

Get 1:1 guidance from people who've walked this exact path — on the InsideEdge app.

Download
InsideEdge