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

💡 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)

Pro Tips

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

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

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.

Related lessons