Skip to main content

Command Palette

Search for a command to run...

Setting up Express.js with TypeScript

Published
5 min read
Setting up Express.js with TypeScript

Audience: Developers familiar with Node.js basics. No prior TypeScript experience required.

TypeScript adds static typing and better tooling to Express. This guide sets up a production-ready Express + TypeScript project from scratch — with separate dev (hot-reload) and build (compiled JS) workflows.


Why this setup matters

Running Express in plain JavaScript means type errors surface at runtime — often in production. TypeScript catches them at compile time. More concretely: without @types/express, your IDE has no idea what req.body or res.status() returns. With it, autocomplete works, typos fail loudly, and refactors are safer.

The tradeoff: a slightly more involved project structure. This guide handles that once so you don't have to think about it again.


Prerequisites

  • Node.js ≥ 18

  • npm ≥ 9

  • VS Code (recommended)


Step 1: Project scaffold

mkdir express-ts-app
cd express-ts-app
npm init -y

npm init -y creates package.json with defaults. You'll update the scripts field shortly.


Step 2: Install TypeScript

npm install typescript --save-dev
npx tsc --init

tsc --init generates tsconfig.json with ~100 commented-out options. Replace it with this minimal, strict config:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "CommonJS",
    "rootDir": "./src",
    "outDir": "./dist",
    "esModuleInterop": true,
    "strict": true
  }
}

Why these settings matter:

Option Why it's here
rootDir: ./src tsc only compiles files inside src/ — keeps accidental files out
outDir: ./dist compiled JS lands in dist/, separate from source
esModuleInterop: true enables import express from 'express' syntax (vs require())
strict: true enables all strict type checks — catches the bugs TypeScript exists to catch

Forgetting esModuleInterop causes a confusing error: Module '"express"' has no default export.


Step 3: Install Express and its types

npm install express
npm install @types/express --save-dev

Express is written in JavaScript and ships no types. @types/express is a separate package that provides TypeScript type definitions for it. Without this, Request and Response are untyped — you lose all autocomplete and type safety.


Step 4: Install ts-node-dev for development

npm install ts-node-dev --save-dev

ts-node-dev runs TypeScript files directly without a compile step, and restarts the server whenever you save a file. It's the development equivalent of nodemon for plain JS.


Step 5: Add npm scripts

Open package.json and update the scripts field:

"scripts": {
  "dev":   "ts-node-dev --respawn --transpile-only src/app.ts",
  "build": "tsc",
  "start": "node dist/app.js"
}
  • --respawn: restart the process if it crashes (not just on file change)

  • --transpile-only: skips full type-checking during development for faster restarts. Type errors still surface in your editor; this just avoids blocking the server restart on them.


Step 6: Write the Express app

Create src/app.ts:

mkdir src
touch src/app.ts
import express, { Request, Response } from "express";

const app = express();
const PORT = 3000;

app.use(express.json());

app.get("/health", (req: Request, res: Response) => {
  res.status(200).json({
    status: "OK",
    uptime: process.uptime(),
  });
});

app.get("/", (req: Request, res: Response) => {
  res.send("Express + TypeScript is running");
});

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

Request and Response are imported from express — not from a separate @types import. That's @types/express doing its job.


Step 7: Run it

Development (hot-reload, no compile step):

npm run dev

Hit http://localhost:3000/health. Expected response:

{
  "status": "OK",
  "uptime": 2.471
}

Production (compile first, then run compiled JS):

npm run build   # tsc → emits dist/app.js
npm start       # node dist/app.js

Results

After following this guide you have:

  • TypeScript type-checking across your entire Express app

  • Hot-reload in development via ts-node-dev

  • A clean src/dist/ compilation pipeline for production

  • Full autocomplete for req, res, and Express middleware


Trade-offs

Compilation overhead: npm run build adds a compile step before deployment. For small projects, this is negligible (~1–2s). For large monorepos, you'll eventually want incremental compilation (tsc --incremental) or a faster bundler like esbuild.

--transpile-only skips type-checking at runtime: Type errors won't block the dev server from starting. Run npx tsc --noEmit in CI to catch them before they ship.

@types/* packages lag behind: Occasionally a major Express update ships before @types/express catches up. Check npmjs.com/package/@types/express if you hit missing type errors after an upgrade.


Conclusion

The core idea: TypeScript lives in src/, compiled JavaScript lives in dist/. Development uses ts-node-dev to skip the compile step. Production uses tsc to emit dist/, which Node runs directly.

Natural next steps: add an MVC folder structure (controllers/, routes/, middleware/), connect a database (Prisma for PostgreSQL, Mongoose for MongoDB), or add JWT authentication.


Further reading