Module P-3·18 min read

tsconfig.json, typing Express handlers and middleware, interfaces vs types, generics, ts-node for dev, tsc for prod — and migrating a JavaScript project incrementally.

Written by Jatin Jain Saraf · Architect of SupraScan

Module P-3 — TypeScript in Node.js

What this module covers: TypeScript catches an entire class of bugs at compile time that JavaScript silently ships to production. This module covers setting up TypeScript for a Node.js API, the tsconfig.json settings that actually matter, typing Express handlers and middleware correctly, the type utilities you will use daily, and how to migrate an existing JavaScript project incrementally without stopping all other work.


Why TypeScript on the Backend

TypeScript's value is not just "autocomplete". It is a documentation system that the compiler enforces. Consider:

// JavaScript — what does this function expect?
async function createOrder({ userId, items, couponCode }) {
  // ...
}
 
// TypeScript — the contract is explicit and verified
interface CreateOrderInput {
  userId: number;
  items: Array<{ productId: number; quantity: number }>;
  couponCode?: string; // optional — the ? makes it clear
}
 
async function createOrder(input: CreateOrderInput): Promise<Order> {
  // ...
}

The TypeScript version:

  • Documents what the function accepts — no need to read the implementation
  • Errors at compile time if you pass userId as a string
  • Errors at the call site if you forget items
  • Autocompletes input. to show all available fields

At scale — hundreds of functions, dozens of developers, months of development — this prevents entire categories of bugs: wrong property names, missing required fields, null dereferences, wrong return type assumptions.


Installation and Setup

npm install -D typescript ts-node @types/node
npx tsc --init  # generates tsconfig.json

Install type definitions for your libraries:

npm install -D @types/express @types/bcrypt @types/jsonwebtoken
# Prisma generates its own types — no @types needed

tsconfig.json: The Settings That Matter

{
  "compilerOptions": {
    // ── Output ─────────────────────────────────────────────
    "target": "ES2022",          // JavaScript version to output
    "module": "NodeNext",        // Use Node's native ESM resolution
    "moduleResolution": "NodeNext",
    "outDir": "./dist",          // compiled JS goes here
    "rootDir": "./src",          // TypeScript source lives here
 
    // ── Type Safety ────────────────────────────────────────
    "strict": true,              // enables all strict checks — always use this
    "noUncheckedIndexedAccess": true,  // arr[0] is T | undefined, not T
    "noImplicitReturns": true,   // all code paths must return a value
    "noFallthroughCasesInSwitch": true,
 
    // ── Interop ────────────────────────────────────────────
    "esModuleInterop": true,     // allows: import express from 'express'
    "allowSyntheticDefaultImports": true,
    "resolveJsonModule": true,   // import config from './config.json'
 
    // ── Dev Experience ─────────────────────────────────────
    "sourceMap": true,           // source maps for debugger and stack traces
    "declaration": true,         // generate .d.ts files (needed for libraries)
    "skipLibCheck": true         // skip type checking node_modules (faster builds)
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

The single most important setting is "strict": true. It enables:

  • strictNullChecks — null and undefined are not assignable to other types
  • noImplicitAny — variables must have explicit types when they can't be inferred
  • strictFunctionTypes — function parameter types are checked contravariantly
  • Several others

Without strict, TypeScript is considerably less useful. Always start with it on.


TypeScript Project Structure

blog-api/
├── src/
│   ├── types/              # shared interfaces and types
│   │   ├── express.d.ts    # augment Express Request type
│   │   └── models.ts       # domain model types
│   ├── routes/
│   ├── controllers/
│   ├── services/
│   ├── repositories/
│   └── index.ts
├── dist/                   # compiled output (git-ignored)
├── tsconfig.json
└── package.json

Update package.json scripts:

{
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js",
    "dev": "ts-node --esm src/index.ts",
    "typecheck": "tsc --noEmit"
  }
}

tsc --noEmit type-checks without producing output — fast, use in CI.

A note on dev: ts-node --esm works, but tsx has become the de facto standard for running TypeScript directly, largely displacing ts-node in new projects — it's esbuild-based (much faster), and handles ESM/CJS interop without a --esm flag or loader configuration. Many teams now write "dev": "tsx watch src/index.ts" instead. This course keeps ts-node --esm in the sample scripts since it's the reference implementation, but reach for tsx if you want less configuration.


Typing Your Domain Models

Define your core types once and import them everywhere:

// src/types/models.ts
 
export interface User {
  id: number;
  name: string;
  email: string;
  passwordHash: string;
  role: 'user' | 'admin' | 'moderator';
  status: 'active' | 'banned';
  createdAt: Date;
}
 
// The shape returned to clients — never expose passwordHash
export type PublicUser = Omit<User, 'passwordHash'>;
 
export interface Post {
  id: number;
  title: string;
  content: string;
  published: boolean;
  authorId: number;
  author?: PublicUser;
  createdAt: Date;
  updatedAt: Date;
}
 
export interface Order {
  id: number;
  userId: number;
  total: number;
  status: 'pending' | 'confirmed' | 'shipped' | 'delivered' | 'cancelled';
  items: OrderItem[];
  createdAt: Date;
}
 
export interface OrderItem {
  id: number;
  orderId: number;
  productId: number;
  quantity: number;
  price: number;
}

Typing Express Handlers

Express's built-in types are usable but loose. Here is the correct pattern:

import { Request, Response, NextFunction, RequestHandler } from 'express';
import { PublicUser } from '../types/models.js';
 
// Typed route handler — explicit req body, params, query, response body
type CreateUserBody = { name: string; email: string; password: string };
type CreateUserResponse = PublicUser;
 
export const createUser: RequestHandler<
  {},                   // req.params shape
  CreateUserResponse,   // res.json() shape
  CreateUserBody,       // req.body shape
  {}                    // req.query shape
> = async (req, res, next) => {
  try {
    const { name, email, password } = req.body; // fully typed
    const user = await usersService.create({ name, email, password });
    res.status(201).json(user);
  } catch (err) {
    next(err);
  }
};
 
// Simpler — type just what you need
export const getUserById = async (
  req: Request<{ id: string }>,
  res: Response<PublicUser | { error: string }>,
  next: NextFunction,
) => {
  try {
    const user = await usersService.findById(parseInt(req.params.id));
    if (!user) return res.status(404).json({ error: 'Not found' });
    res.json(user);
  } catch (err) {
    next(err);
  }
};

Augmenting the Express Request Type

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.