Skip to content
Express.js ex typescript 4 min read

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:

OptionValueWhy it matters
stricttrueTurns on strictNullChecks and friends; forces you to handle undefined query params and bodies
esModuleInteroptrueLets you write import express from 'express' against a CommonJS module
module / moduleResolutionNodeNextResolves modules the way modern Node does, supporting both ESM and CJS
outDir / rootDirdist / srcKeeps generated .js files out of your source tree
sourceMaptrueMaps 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-only means a type error will not crash the dev server. Always keep a tsc --noEmit step 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 commit dist/ — add it to .gitignore and build in CI or your container image.
  • Enable strict from 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 a tsc --noEmit step so errors are never silenced.
  • Compile to plain JavaScript for production rather than running ts-node in the runtime — it starts faster and removes the compiler dependency.
  • Pin @types/express to the major version of Express you run so the typings match the actual router and middleware signatures.
  • Add sourceMap: true so production stack traces map back to your TypeScript source.
Last updated June 14, 2026
Was this helpful?