Setting Up Express with TypeScript
TypeScript brings static types, editor autocompletion, and compile-time safety to Express applications, catching whole classes of bugs before a request ever reaches your server. Because Express ships without bundled type definitions, a TypeScript setup is a few deliberate steps: install the compiler and the community @types packages, configure tsconfig.json, run a fast dev loop with ts-node-dev, and produce a plain-JavaScript build for production. This page walks through each piece end to end.
Installing TypeScript and the types
Express itself is written in JavaScript, so you install the runtime package as usual and add the TypeScript toolchain plus type definitions as development dependencies. The @types/express package supplies the Request, Response, and NextFunction types; @types/node covers the Node runtime globals like process and Buffer.
npm init -y
npm install express
npm install -D typescript ts-node-dev @types/express @types/node
Generate a starter tsconfig.json with the compiler’s init flag, then edit it (the next section shows the recommended settings):
npx tsc --init
Tip:
@types/*packages are versioned independently of the libraries they describe. If you upgrade to Express 5, install a matching@types/express(5.x) so the typings reflect the new router signatures.
Configuring tsconfig.json
The compiler options govern how strict your types are, which JavaScript features compile down, and where output lands. The following tsconfig.json targets a modern Node LTS, enables full strictness, and separates source (src/) from compiled output (dist/).
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"sourceMap": true,
"declaration": false
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}
A few of these options carry real weight in an Express project:
| Option | Value | Why it matters |
|---|---|---|
strict | true | Turns on strictNullChecks and friends; forces you to handle undefined query params and bodies |
esModuleInterop | true | Lets you write import express from 'express' against a CommonJS module |
module / moduleResolution | NodeNext | Resolves modules the way modern Node does, supporting both ESM and CJS |
outDir / rootDir | dist / src | Keeps generated .js files out of your source tree |
sourceMap | true | Maps runtime stack traces back to your .ts lines for debugging |
Writing the server in TypeScript
With the types installed, Express’s API is fully typed. Annotate handler parameters with the imported types and you get autocompletion on req.params, res.status(), and the rest. Note that error-handling middleware still takes four arguments, with the error typed explicitly.
// src/server.ts
import express, { Request, Response, NextFunction } from 'express';
const app = express();
app.use(express.json());
app.get('/health', (req: Request, res: Response) => {
res.json({ status: 'ok' });
});
app.get('/users/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const id = Number(req.params.id);
if (Number.isNaN(id)) {
return res.status(400).json({ error: 'id must be a number' });
}
res.json({ id, name: 'Ada Lovelace' });
} catch (err) {
next(err);
}
});
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
res.status(500).json({ error: err.message });
});
const PORT = Number(process.env.PORT) || 3000;
app.listen(PORT, () => console.log(`Listening on http://localhost:${PORT}`));
The development loop with ts-node-dev
During development you want to run TypeScript directly and restart on every file change, without a separate compile step. ts-node-dev does exactly that: it type-checks and executes .ts files in one process and reloads only the changed module. Wire it into an npm script.
{
"scripts": {
"dev": "ts-node-dev --respawn --transpile-only src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"typecheck": "tsc --noEmit"
}
}
Run the dev server:
npm run dev
Output:
[INFO] 14:02:11 ts-node-dev ver. 2.0.0 (using ts-node ver. 10.9.2, typescript ver. 5.4.5)
Listening on http://localhost:3000
The --transpile-only flag skips type-checking at runtime for a faster reload, which is why the separate typecheck script exists — run it in CI or a pre-commit hook to enforce types without slowing the dev loop.
Warning:
--transpile-onlymeans a type error will not crash the dev server. Always keep atsc --noEmitstep in your pipeline so broken types never slip through to a build.
Building and running for production
For production you do not ship TypeScript or ts-node. Compile once with tsc, which emits plain JavaScript into dist/, then run that with Node. This is faster to start and has no compiler in the runtime path.
npm run build # tsc -> dist/server.js
npm start # node dist/server.js
A typical dist/ after a build:
dist/
server.js
server.js.map
The package.json should point its main field at the compiled entry (dist/server.js) and you can wire a prepare or container CMD to npm start.
Best Practices
- Keep all source in
src/and never commitdist/— add it to.gitignoreand build in CI or your container image. - Enable
strictfrom day one; retrofitting strict null checks into a large codebase later is far more painful than starting with it on. - Use
ts-node-dev(with--transpile-only) for the dev loop but enforce types separately via atsc --noEmitstep so errors are never silenced. - Compile to plain JavaScript for production rather than running
ts-nodein the runtime — it starts faster and removes the compiler dependency. - Pin
@types/expressto the major version of Express you run so the typings match the actual router and middleware signatures. - Add
sourceMap: trueso production stack traces map back to your TypeScript source.