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-devA clean
src/→dist/compilation pipeline for productionFull 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.


