TypeScriptTypeScript with Express

TypeScript with Express

Express is the most popular Node.js web framework, and TypeScript makes it significantly more robust. With typed request/response objects, validated route params, and strongly-typed middleware, you catch entire classes of API bugs at compile time rather than in production.

This page walks through a complete, real-world Express + TypeScript setup.

Installation

Bash
npm install express
npm install --save-dev typescript ts-node @types/node @types/express ts-node-dev
tsconfig.json

JSON
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "CommonJS",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "resolveJsonModule": true,
    "declaration": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}
Basic Server Setup

TS
// src/index.ts
import express, { Application, Request, Response, NextFunction } from 'express';

const app: Application = express();

app.use(express.json());
app.use(express.urlencoded({ extended: true }));

app.get('/health', (_req: Request, res: Response) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

const PORT = parseInt(process.env['PORT'] ?? '3000', 10);

app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

export default app;
Typing Request Bodies, Params, and Query

Request is a generic type: Request<Params, ResBody, ReqBody, Query>. Type each position to get full safety:

TS
import { Request, Response } from 'express';

// Define interfaces for each part of the request
interface CreateUserBody {
  name:  string;
  email: string;
  role?: 'admin' | 'user';
}

interface UserParams {
  id: string;
}

interface UserQuery {
  include?: 'posts' | 'orders';
  limit?:   string;  // query params are always strings
}

// POST /users — typed body
app.post(
  '/users',
  async (req: Request<{}, {}, CreateUserBody>, res: Response) => {
    const { name, email, role = 'user' } = req.body;
    // name, email, role are fully typed — no any

    const user = await UserService.create({ name, email, role });
    res.status(201).json(user);
  }
);

// GET /users/:id — typed params and query
app.get(
  '/users/:id',
  async (req: Request<UserParams, {}, {}, UserQuery>, res: Response) => {
    const { id }      = req.params;   // string
    const { include } = req.query;    // 'posts' | 'orders' | undefined

    const user = await UserService.findById(id, include);
    if (!user) return res.status(404).json({ error: 'User not found' });

    res.json(user);
  }
);
Note
Query string values are always string | undefined at runtime. Parse numbers with parseInt inside the handler or use a validation library.
Typed Middleware

Middleware is typed with RequestHandler or by annotating the standard (req, res, next) signature. You can extend Request with custom properties using declaration merging:

TS
// src/types/express.d.ts — extend the Request type globally
import { JwtPayload } from 'jsonwebtoken';

declare global {
  namespace Express {
    interface Request {
      user?: {
        id:    string;
        email: string;
        role:  'admin' | 'user';
      };
    }
  }
}

TS
// src/middleware/auth.ts
import { Request, Response, NextFunction, RequestHandler } from 'express';
import jwt from 'jsonwebtoken';

export const authenticate: RequestHandler = (
  req: Request,
  res: Response,
  next: NextFunction
): void => {
  const header = req.headers['authorization'];
  if (!header?.startsWith('Bearer ')) {
    res.status(401).json({ error: 'Missing token' });
    return;
  }

  const token = header.slice(7);
  try {
    const payload = jwt.verify(token, process.env['JWT_SECRET']!) as {
      id: string; email: string; role: 'admin' | 'user';
    };
    req.user = payload;  // now available on req.user everywhere
    next();
  } catch {
    res.status(401).json({ error: 'Invalid token' });
  }
};

// Protected route — req.user is available after authenticate runs
app.get('/profile', authenticate, (req: Request, res: Response) => {
  res.json(req.user);  // { id, email, role }
});
Typed Error Handling Middleware

Express error handlers take four parameters. TypeScript recognises this signature and types them accordingly:

TS
// src/middleware/errorHandler.ts
import { Request, Response, NextFunction, ErrorRequestHandler } from 'express';

class AppError extends Error {
  constructor(
    public readonly statusCode: number,
    message: string,
    public readonly code?: string
  ) {
    super(message);
    this.name = 'AppError';
  }
}

const errorHandler: ErrorRequestHandler = (
  err: unknown,
  _req: Request,
  res: Response,
  _next: NextFunction
): void => {
  if (err instanceof AppError) {
    res.status(err.statusCode).json({
      error:   err.message,
      code:    err.code,
      status:  err.statusCode,
    });
    return;
  }

  if (err instanceof Error) {
    res.status(500).json({ error: err.message, status: 500 });
    return;
  }

  res.status(500).json({ error: 'Unknown error', status: 500 });
};

// Must be the LAST middleware registered
app.use(errorHandler);

// Usage in routes — throw AppError to send structured error responses
app.get('/users/:id', async (req, res, next) => {
  try {
    const user = await UserService.findById(req.params.id);
    if (!user) throw new AppError(404, 'User not found', 'USER_NOT_FOUND');
    res.json(user);
  } catch (err) {
    next(err);  // delegate to errorHandler
  }
});
Router Organisation

TS
// src/routes/users.router.ts
import { Router, Request, Response, NextFunction } from 'express';
import { authenticate } from '../middleware/auth';

const router = Router();

router.get('/',    authenticate, listUsers);
router.post('/',   createUser);
router.get('/:id', authenticate, getUser);
router.put('/:id', authenticate, updateUser);
router.delete('/:id', authenticate, deleteUser);

async function listUsers(_req: Request, res: Response, next: NextFunction) {
  try {
    const users = await UserService.findAll();
    res.json(users);
  } catch (err) { next(err); }
}

async function createUser(req: Request<{}, {}, CreateUserBody>, res: Response, next: NextFunction) {
  try {
    const user = await UserService.create(req.body);
    res.status(201).json(user);
  } catch (err) { next(err); }
}

async function getUser(req: Request<{ id: string }>, res: Response, next: NextFunction) {
  try {
    const user = await UserService.findById(req.params.id);
    if (!user) return res.status(404).json({ error: 'Not found' });
    res.json(user);
  } catch (err) { next(err); }
}

export { router as usersRouter };

// src/index.ts
import { usersRouter } from './routes/users.router';
app.use('/api/users', usersRouter);
Validation with Zod

Zod is the ideal companion for Express — it validates request bodies at runtime and infers TypeScript types from the schema:

Bash
npm install zod

TS
import { z } from 'zod';
import { Request, Response, NextFunction, RequestHandler } from 'express';

// Define schema — infer TypeScript type from it
const CreateUserSchema = z.object({
  name:  z.string().min(2).max(100),
  email: z.string().email(),
  age:   z.number().int().min(0).max(150).optional(),
  role:  z.enum(['admin', 'user']).default('user'),
});

type CreateUserDto = z.infer<typeof CreateUserSchema>;

// Generic middleware factory that validates the request body
function validateBody<T extends z.ZodTypeAny>(schema: T): RequestHandler {
  return (req: Request, res: Response, next: NextFunction): void => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      res.status(400).json({
        error:  'Validation failed',
        issues: result.error.flatten().fieldErrors,
      });
      return;
    }
    req.body = result.data;  // replace with validated/transformed data
    next();
  };
}

// Use in routes
app.post(
  '/users',
  validateBody(CreateUserSchema),
  async (req: Request<{}, {}, CreateUserDto>, res: Response, next: NextFunction) => {
    try {
      const user = await UserService.create(req.body);
      res.status(201).json(user);
    } catch (err) { next(err); }
  }
);
Tip
Pair Zod with Express to get both runtime validation (Zod) and compile-time type safety (TypeScript inferred from Zod schema). This eliminates the need to write the same structure twice.
Async Handler Wrapper

Express does not catch Promise rejections from async route handlers by default (in versions before Express 5). A tiny wrapper fixes this:

TS
import { Request, Response, NextFunction, RequestHandler } from 'express';

// Wrap an async handler and forward errors to next()
function asyncHandler(
  fn: (req: Request, res: Response, next: NextFunction) => Promise<void>
): RequestHandler {
  return (req, res, next) => {
    fn(req, res, next).catch(next);
  };
}

// Much cleaner routes — no try/catch needed
app.get('/users/:id', asyncHandler(async (req, res) => {
  const user = await UserService.findById(req.params.id);
  if (!user) throw new AppError(404, 'User not found');
  res.json(user);
}));

app.post('/users', validateBody(CreateUserSchema), asyncHandler(async (req, res) => {
  const user = await UserService.create(req.body as CreateUserDto);
  res.status(201).json(user);
}));
Complete Project Structure

Bash
src/
  index.ts                 # App entry — creates and starts server
  app.ts                   # Creates Express app, registers middleware/routes
  config.ts                # Typed environment config
  types/
    express.d.ts           # Augmented Express.Request
    index.ts               # Shared interfaces (User, Order, etc.)
  routes/
    users.router.ts
    orders.router.ts
  middleware/
    auth.ts                # JWT authentication
    errorHandler.ts        # Global error handler
    validateBody.ts        # Zod validation factory
  services/
    user.service.ts        # Business logic
    order.service.ts
  models/
    user.model.ts          # DB model (TypeORM/Prisma/etc.)
  utils/
    asyncHandler.ts
    logger.ts
Key Middleware Typing Reference

Type

Signature

Use for

RequestHandler

(req, res, next) => void

Standard middleware and route handlers

ErrorRequestHandler

(err, req, res, next) => void

Error handling middleware (4 params)

Request<P,RB,RQ,Q>

Generic request

Typed params, response body, request body, query

Response<RB>

Generic response

Typed response body for res.json()

NextFunction

(err?: unknown) => void

Call next() or next(err)

Key Takeaways
  1. Install @types/express for full TypeScript support across Request, Response, and Router

  2. Type Request generics: Request<Params, ResBody, ReqBody, Query> for full safety

  3. Extend Express.Request via declaration merging to add custom properties like req.user

  4. Error handlers need all four parameters (err, req, res, next) to be recognised by Express

  5. Create an asyncHandler wrapper to forward async errors to Express error handlers

  6. Zod schemas validate at runtime and infer TypeScript types — no duplication needed

  7. Organise routes into Router files and mount them in the main app

  8. Never use any for req.body — always type it through Request<{}, {}, MyBodyType>