← All writings ES

Using TypeScript in a Node.js Project

Set up TypeScript in an Express project from scratch — tsconfig, type-safe request handlers, build scripts, and the patterns that make it worth the setup.

TypeScript in Node.js is one of those things that feels like overhead until you’ve shipped something without it and spent an afternoon debugging a cannot read property of undefined that a compiler would have caught at zero cost.

This guide sets up TypeScript in an Express project from scratch — not just the boilerplate, but the reasoning behind each decision.

Initialize the project

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

Install dependencies

TypeScript and its type definitions are development dependencies. The compiled output is plain JavaScript, so none of the TypeScript tooling ships to production.

# Runtime
npm install express

# Development only
npm install -D typescript ts-node @types/node @types/express
  • typescript — the compiler
  • ts-node — runs .ts files directly without a build step, useful for development
  • @types/node and @types/express — type definitions for Node’s built-ins and Express’s API

Configure TypeScript

Generate a tsconfig.json:

npx tsc --init

Then configure it for a Node.js target:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./build",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "build"]
}

Key options:

  • strict: true — enables all strict checks. This is the point of TypeScript; don’t disable it.
  • outDir: "./build" — compiled JS goes here, never commit it
  • esModuleInterop: true — allows import express from 'express' instead of import * as express from 'express'

Project structure

ts-node-app/
├── src/
│   ├── app.ts          ← Express app setup
│   └── server.ts       ← entry point, starts the server
├── build/              ← compiled output (gitignored)
├── tsconfig.json
└── package.json

Write the Express app

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

const app: Application = express();

app.use(express.json());

app.get('/', (req: Request, res: Response) => {
  res.json({ status: 'ok', message: 'Hello, TypeScript.' });
});

app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  console.error(err.stack);
  res.status(500).json({ error: err.message });
});

export default app;
// src/server.ts
import app from './app';

const PORT = process.env.PORT ?? 3000;

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

Separating app from server makes the app importable in tests without binding to a port.

Add build scripts

{
  "scripts": {
    "dev": "ts-node src/server.ts",
    "build": "tsc --project ./",
    "start": "node ./build/server.js"
  }
}
  • npm run dev — development, no compilation step, instant feedback
  • npm run build — compiles TypeScript to ./build
  • npm start — runs the compiled output in production

Type-safe route handlers

The real value shows up when you start defining typed request/response shapes:

// src/routes/users.ts
import { Router, Request, Response } from 'express';

interface CreateUserBody {
  name: string;
  email: string;
}

interface UserParams {
  id: string;
}

const router = Router();

router.post('/', (req: Request<{}, {}, CreateUserBody>, res: Response) => {
  const { name, email } = req.body; // fully typed — no any
  // TypeScript will error if you access req.body.nonexistent
  res.status(201).json({ id: '1', name, email });
});

router.get('/:id', (req: Request<UserParams>, res: Response) => {
  const { id } = req.params; // string, guaranteed
  res.json({ id, name: 'Esteban', email: 'e@example.com' });
});

export default router;

The Request<Params, ResBody, ReqBody, Query> generic gives you full type coverage across the entire request lifecycle.

Environment variables with type safety

// src/config.ts
function requireEnv(key: string): string {
  const value = process.env[key];
  if (!value) throw new Error(`Missing required environment variable: ${key}`);
  return value;
}

export const config = {
  port: parseInt(process.env.PORT ?? '3000', 10),
  dbUrl: requireEnv('DATABASE_URL'),
  nodeEnv: (process.env.NODE_ENV ?? 'development') as 'development' | 'production' | 'test',
};

requireEnv fails fast at startup rather than at runtime when the variable is actually used.

Add .gitignore

node_modules/
build/
.env

The build/ folder is generated — never commit it.

Key takeaways

  • TypeScript dependencies (typescript, ts-node, @types/*) are devDependencies — they don’t ship to production
  • strict: true is the entire point. Disable it and you’re just writing JavaScript with extra steps
  • Separate app.ts from server.ts — it makes testing significantly cleaner
  • Request<Params, ResBody, ReqBody, Query> generics are how you get type safety into Express handlers
  • ts-node in development, compiled JavaScript in production — never run ts-node in a prod environment

Once you’ve typed a production Express service, going back to untyped Node feels like working without autocomplete. The setup cost is about 15 minutes; the payoff compounds across every refactor.