Building APIs with Express and TypeScript
Reviewed & published by Brayan K
Build a fully typed REST API. You'll wire up express with @types/express, type Request and Response (including the Request<Params, ResBody, ReqBody> generics), write typed middleware and a Router, handle errors, and validate bodies with Zod.
Part of the free TypeScript course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- Set up express with @types/express
- Type route handlers with Request and Response
- Type the body via Request<Params, ResBody, ReqBody>
- Write middleware and short-circuit with next()
- Group routes with express.Router()
- Handle errors and validate bodies with Zod
💡 Real-World Analogy
An Express app is a restaurant kitchen. Each route is a dish on the menu, the handler is the chef who prepares it, and middleware are the prep stations every order passes through — washing, seasoning, plating — before it reaches the chef. next() is sliding the ticket to the next station; a failed check (no reservation) sends the order back without ever reaching the stove.
1. A Typed Express App
An Express app registers routes, each handled by a function that receives a typed Request and Response. The Request<Params, ResBody, ReqBody> generics let you type req.body. Read the real, fully-typed app here:
import express, { Request, Response, NextFunction } from "express";
const app = express();
app.use(express.json()); // body-parsing middleware -> fills req.body
// A typed route handler:
app.get("/health", (req: Request, res: Response) => {
res.status(200).json({ ok: true });
});
// Typing the BODY with the Request generics:
// Request<Params, ResBody, ReqBody, ReqQuery>
interface CreateUserBody { name: string; email: string; }
app.post("/users", (req: Request<{}, {}, CreateUserBody>, res: Response) => {
const { name, email } = req.body; // typed as CreateUserBody
res.status(201).json({ id: 1, name, email });
});
// Error-handling middleware has FOUR params: (err, req, res, next)
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
res.status(500).json({ error: err.message });
});
app.listen(3000, () => console.log("listening on :3000"));2. The Request / Response Model
A handler is just a function of (req, res). You read input from req (params, query, body) and build the reply on res — res.status(code).json(payload) chains together. The box models req/res so you can run it:
// An Express handler is just a function (req, res). We model req/res here
// so the box runs anywhere; real Express provides typed versions.
function makeRes() {
const res = {
statusCode: 200,
body: null,
status(code) { this.statusCode = code; return this; }, // chainable
json(payload) { this.body = payload; return this; }, // sends JSON
};
return res;
}
// GET /health
function health(req, res) {
res.status(200).json({ ok: true });
}
// POST /users — reads the typed body
function createUser(req, res) {
const { name, email } = req.body;
res.status(201).json({ id: 1, name, email });
}
const r1 = makeRes();
health({}, r1);
console.log(r1.statusCode, r1.body); // 200 { ok: true }
const r2 = makeRes();
createUser({ body: { name: "Ada", email: "ada@x.com" } }, r2);
console.log(r2.statusCode, r2.body); // 201 { id: 1, name: 'Ada', email: 'ada@x.com' }3. Middleware & next()
Middleware is a function (req, res, next) that runs before (or around) your handler. It can log, authenticate, parse a body, then call next() to continue — or send a response and not call next() to stop the chain. The box runs a small middleware chain:
// MIDDLEWARE is a function (req, res, next). It can inspect the request and
// then call next() to pass control, or end the response itself.
// A logging middleware:
function logger(req, res, next) {
console.log(req.method + " " + req.url);
next(); // hand off to the next function
}
// An auth-check middleware that can SHORT-CIRCUIT:
function requireToken(req, res, next) {
if (!req.headers || req.headers.authorization !== "secret") {
res.statusCode = 401;
res.body = { error: "Unauthorized" };
return; // do NOT call next() -> stop the chain
}
next();
}
// The final handler:
function handler(req, res) {
res.statusCode = 200;
res.body = { ok: true };
}
// Run a tiny chain manually:
function run(req) {
const res = { statusCode: 0, body: null };
logger(req, res, () => requireToken(req, res, () => handler(req, res)));
return res;
}
console.log(run({ method: "GET", url: "/users", headers: { authorization: "secret" } }));
// GET /users then { statusCode: 200, body: { ok: true } }
console.log(run({ method: "GET", url: "/users", headers: {} }));
// GET /users then { statusCode: 401, body: { error: 'Unauthorized' } }4. Routing, Router & Zod Validation
An express.Router() groups related routes into a mountable mini-app (app.use("/users", router)). Because Express's types are compile-time only, you pair them with a Zod schema to validate req.body at runtime. The read-only block shows the real Router + Zod pattern; the box models the routing idea:
import { Router, Request, Response } from "express";
import { z } from "zod";
const router = Router(); // a mountable mini-app
// Zod validates the ACTUAL runtime body (TS types are erased):
const CreateUser = z.object({ name: z.string().min(1), email: z.string().email() });
router.post("/", (req: Request, res: Response) => {
const result = CreateUser.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ error: "Invalid body" });
}
const user = result.data; // validated AND typed
res.status(201).json({ id: 1, ...user });
});
// Mount it: app.use("/users", router);
export default router;// A tiny router that maps "METHOD path" to a handler — the idea behind
// express.Router(). Real Express adds typing and pattern matching.
const routes = {};
function route(method, path, handler) { routes[method + " " + path] = handler; }
route("GET", "/users", (req, res) => res.status(200).json([{ id: 1, name: "Ada" }]));
route("GET", "/users/1", (req, res) => res.status(200).json({ id: 1, name: "Ada" }));
route("GET", "/users/99", (req, res) => res.status(404).json({ error: "Not found" }));
function dispatch(method, path) {
const handler = routes[method + " " + path];
const res = { statusCode: 0, body: null, status(c) { this.statusCode = c; return this; }, json(b) { this.body = b; return this; } };
if (!handler) { res.status(404).json({ error: "No route" }); return res; }
handler({}, res);
return res;
}
console.log(dispatch("GET", "/users")); // 200 [ { id: 1, name: 'Ada' } ]
console.log(dispatch("GET", "/users/1")); // 200 { id: 1, name: 'Ada' }
console.log(dispatch("GET", "/users/99")); // 404 { error: 'Not found' }
console.log(dispatch("GET", "/missing")); // 404 { error: 'No route' }Your turn. Fill in the two blanks marked ___ to finish a "create user" handler that returns 201, then run it.
// 🎯 YOUR TURN — replace each ___ then press "Try it Yourself".
// Finish a handler that creates a user and returns 201 Created.
function makeRes() {
return { statusCode: 0, body: null, status(c) { this.statusCode = c; return this; }, json(b) { this.body = b; return this; } };
}
function createUser(req, res) {
const { name } = req.body;
// 1) "Created" responses use status code 201
res.status(___).json({ id: 1, name }); // 👉 the number 201
}
const res = makeRes();
// 2) Pass the body the handler reads from
createUser({ body: { ___: "Ada" } }, res); // 👉 the key name "name"
console.log(res.statusCode, res.body); // 201 { id: 1, name: 'Ada' }
// ✅ Expected output:
// 201 { id: 1, name: 'Ada' }Common Errors (and the fix)
- ❌ req.body is undefined — no body parser is registered. ✅ Add app.use(express.json()) before your routes.
- ❌ Trusting req.body because it's typed — the type is erased at runtime. ✅ Validate it with a Zod .safeParse() and return 400 on failure.
- ❌ Error middleware never runs — you wrote three params. ✅ Error handlers must declare all four: (err, req, res, next).
- ❌ Calling next() after sending a response — causes "headers already sent". ✅ return after res.json(...) to stop the chain.
Pro Tips
- 💡 Type the body generic — Request<{}, {}, CreateUserBody> turns req.body into a checked object.
- 💡 Validate at the edge — one Zod .safeParse(req.body) per write route stops bad data cold.
- 💡 Group with Router — a router per resource keeps app.ts small and the structure obvious.
- 💡 One error handler — a final (err, req, res, next) middleware centralises 500s and logging.
Mini-Challenge: A Validating Handler
No blanks this time — write handleCreate yourself, validating a non-empty title and returning 201 or 400, then run it against a good and a bad body.
// 🎯 MINI-CHALLENGE: a validating POST handler
//
// Combine routing + validation. Write handleCreate(req, res) that:
// 1. Reads req.body and checks it has a non-empty string "title".
// 2. If invalid, respond 400 with { error: 'Invalid body' }.
// 3. If valid, respond 201 with { id: 1, title: req.body.title }.
//
// Test with a good body and a bad one.
//
// ✅ Expected output:
// 201 { id: 1, title: 'Hello' }
// 400 { error: 'Invalid body' }
function makeRes() {
return { statusCode: 0, body: null, status(c) { this.statusCode = c; return this; }, json(b) { this.body = b; return this; } };
}
// your handleCreate here, then:
// const a = makeRes(); handleCreate({ body: { title: "Hello" } }, a); console.log(a.statusCode, a.body);
// const b = makeRes(); handleCreate({ body: {} }, b); console.log(b.statusCode, b.body);🎉 Lesson Complete
- ✅ @types/express types Request, Response, and Router
- ✅ Handlers are (req, res); res.status(code).json(body) sends the reply
- ✅ Request<Params, ResBody, ReqBody> types the params, response, and body
- ✅ Middleware is (req, res, next); error handlers take four params
- ✅ Pair Express with Zod to validate req.body at runtime
Practice quiz
Why install @types/express alongside express?
- It provides the TypeScript types for Express's Request, Response, and Router
- It speeds up routing
- It replaces express
- It is the runtime server
Answer: It provides the TypeScript types for Express's Request, Response, and Router. express ships as JavaScript; @types/express adds the type declarations so Request, Response, and middleware are typed.
What two core objects does an Express route handler receive?
- get and post
- url and body
- req and res (Request and Response)
- input and output
Answer: req and res (Request and Response). A handler has the signature (req: Request, res: Response): the incoming request and the outgoing response.
What is Express middleware?
- A database driver
- A function that runs during the request/response cycle and can call next()
- A type definition
- A test runner
Answer: A function that runs during the request/response cycle and can call next(). Middleware is a function (req, res, next) that can inspect or modify the request, end it, or pass control with next().
Which generic signature lets you type the request body?
- Request only accepts one type
- Body<Request>
- Request<Body>
- Request<Params, ResBody, ReqBody>
Answer: Request<Params, ResBody, ReqBody>. Express types Request as Request<Params, ResBody, ReqBody, ReqQuery>, so the third parameter types req.body.
What does express.json() middleware do?
- Parses incoming JSON request bodies into req.body
- Converts responses to XML
- Starts the server
- Renders HTML
Answer: Parses incoming JSON request bodies into req.body. express.json() is body-parsing middleware that reads a JSON request body and populates req.body.
What is the purpose of an express.Router()?
- To type the response
- To group related routes into a mountable mini-application
- To start the HTTP server
- To validate JSON
Answer: To group related routes into a mountable mini-application. A Router bundles related routes and middleware so they can be mounted under a path like app.use('/users', router).
How do you send a 404 JSON response in a handler?
- req.send(404)
- return 404
- res.error(404)
- res.status(404).json({ error: 'Not found' })
Answer: res.status(404).json({ error: 'Not found' }). res.status(404) sets the status code and .json(...) serialises and sends the body; they chain together.
What signature identifies an Express error-handling middleware?
- (res, req)
- (req, res)
- (err, req, res, next) with four parameters
- (next)
Answer: (err, req, res, next) with four parameters. Express recognises error middleware by its FOUR parameters: (err, req, res, next).
How does Zod combine well with an Express API?
- It replaces Express
- It validates req.body at runtime before the handler trusts it
- It compiles the routes
- It renders templates
Answer: It validates req.body at runtime before the handler trusts it. Express types are compile-time only, so you run req.body through a Zod schema to validate the actual incoming data at runtime.
Why type req.body with a generic instead of leaving it as any?
- So the compiler checks how you read the body and autocompletes its fields
- It changes the HTTP method
- any is required by Express
- It makes Express faster
Answer: So the compiler checks how you read the body and autocompletes its fields. Typing the body via Request<...> means the compiler verifies the fields you access and provides autocomplete, catching typos.
Continue this course
- Previous: TypeScript on Node.js
- Next: Authoring and Publishing TypeScript Libraries — Emit .d.ts declarations, configure package.json exports, and publish to npm
- Quick reference: TypeScript cheat sheet
Frequently asked questions
Do I need @types/express, and what does it give me?
Yes. The express package itself is plain JavaScript, so it carries no types. @types/express (installed as a dev dependency) provides the declarations for Request, Response, NextFunction, Router, and the app methods, so your handlers are type-checked and you get autocomplete on req and res. Without it, everything would be implicitly any.
How do I type the request body, params, and query?
Express types Request with four generic parameters: Request<Params, ResBody, ReqBody, ReqQuery>. To type just the body you write Request<{}, {}, CreateUserBody>, which makes req.body a CreateUserBody. The first slot types req.params (route parameters) and the fourth types req.query. Filling these in turns req from a bag of any into a checked, autocompleting object.
What exactly is middleware?
Middleware is a function with the signature (req, res, next) that runs during the request/response cycle. It can read or modify the request, send a response and stop the chain, or call next() to pass control to the next function. express.json() is built-in middleware that parses JSON bodies; logging, authentication, and error handling are all commonly implemented as middleware. Error-handling middleware is special: it takes four parameters, (err, req, res, next).
Why validate with Zod when Express already has TypeScript types?
Because the TypeScript types are compile-time only and are erased before the server runs. A declared Request<{}, {}, CreateUserBody> is a promise about what the body should be, but the network can deliver anything. Running req.body through a Zod schema with .safeParse() validates the actual incoming data at runtime, so you reject malformed requests with a 400 before any handler trusts them.