Back/Module P-3 TypeScript in Node.js
Module P-3·23 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.

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
// 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

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

Install type definitions for your libraries:

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

tsconfig.json: The Settings That Matter

json
{ "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:

  • strictNullChecksnull 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:

json
{ "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.


Typing Your Domain Models

Define your core types once and import them everywhere:

typescript
// 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:

typescript
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

The authenticate middleware from P-2 adds req.user — but TypeScript does not know that. Fix it with module augmentation:

typescript
// src/types/express.d.ts import { Request } from 'express'; declare global { namespace Express { interface Request { user?: { id: number; role: 'user' | 'admin' | 'moderator'; }; requestId?: string; } } }

After this, req.user is fully typed in every handler — no casting needed:

typescript
export const getProfile = async (req: Request, res: Response, next: NextFunction) => { // req.user is typed as { id: number; role: string } | undefined if (!req.user) return res.status(401).json({ error: 'Unauthorized' }); const user = await usersService.findById(req.user.id); // req.user.id is number res.json(user); };

Interfaces vs Types

Both define object shapes. The differences that matter in practice:

typescript
// interface — can be extended and merged (declaration merging) interface Animal { name: string; } interface Dog extends Animal { breed: string; } // Multiple declarations of the same interface merge interface Request { user?: User; } interface Request { requestId?: string; } // Result: Request has both user and requestId // type alias — more flexible, can represent unions and intersections type ID = number | string; type Status = 'active' | 'banned'; type AdminUser = User & { permissions: string[] }; // intersection type ApiResponse<T> = { data: T; error: null } | { data: null; error: string };

Practical rule: use interface for object shapes (models, DTOs, service inputs). Use type for unions, intersections, and computed types (Omit, Pick, Partial).


Utility Types You Use Daily

typescript
interface User { id: number; name: string; email: string; passwordHash: string; role: 'user' | 'admin'; createdAt: Date; } // Omit — remove fields type PublicUser = Omit<User, 'passwordHash'>; // Pick — keep only specific fields type UserSummary = Pick<User, 'id' | 'name'>; // Partial — all fields optional (for update DTOs) type UpdateUserInput = Partial<Pick<User, 'name' | 'email' | 'role'>>; // Required — all fields required type CompleteUser = Required<User>; // Readonly — prevent mutation type ImmutableUser = Readonly<User>; // Record — dictionary type type RolePermissions = Record<User['role'], string[]>; // { user: string[]; admin: string[] } // ReturnType — infer the return type of a function async function findUser(id: number): Promise<User | null> { /* ... */ } type FindUserResult = Awaited<ReturnType<typeof findUser>>; // → User | null // Parameters — infer function parameters type FindUserParams = Parameters<typeof findUser>; // → [id: number] // NonNullable — remove null and undefined type DefiniteUser = NonNullable<User | null | undefined>; // → User

Generics: Writing Flexible Typed Code

Generics let you write functions and types that work with any type while preserving type information:

typescript
// Generic repository interface interface Repository<T, ID = number> { findById(id: ID): Promise<T | null>; findAll(): Promise<T[]>; create(data: Omit<T, 'id' | 'createdAt'>): Promise<T>; update(id: ID, data: Partial<T>): Promise<T | null>; delete(id: ID): Promise<boolean>; } // Typed API response wrapper type ApiResponse<T> = | { success: true; data: T } | { success: false; error: string; code: number }; function ok<T>(data: T): ApiResponse<T> { return { success: true, data }; } function fail(error: string, code = 500): ApiResponse<never> { return { success: false, error, code }; } // Paginated result interface Paginated<T> { data: T[]; total: number; page: number; limit: number; hasMore: boolean; } async function paginateQuery<T>( query: () => Promise<T[]>, countQuery: () => Promise<number>, page: number, limit: number, ): Promise<Paginated<T>> { const [data, total] = await Promise.all([query(), countQuery()]); return { data, total, page, limit, hasMore: page * limit < total }; }

Typing Service and Repository Layers

typescript
// src/repositories/users.repository.ts import prisma from '../db/prisma.js'; import { User, PublicUser } from '../types/models.js'; export async function findById(id: number): Promise<PublicUser | null> { return prisma.user.findUnique({ where: { id }, select: { id: true, name: true, email: true, role: true, createdAt: true }, }); } export async function findByEmail(email: string): Promise<User | null> { return prisma.user.findUnique({ where: { email } }); } export async function create(data: { name: string; email: string; passwordHash: string; }): Promise<PublicUser> { return prisma.user.create({ data, select: { id: true, name: true, email: true, role: true, createdAt: true }, }); }
typescript
// src/services/users.service.ts import * as usersRepo from '../repositories/users.repository.js'; import { AppError } from '../errors/AppError.js'; import { PublicUser } from '../types/models.js'; export async function findById(id: number): Promise<PublicUser> { const user = await usersRepo.findById(id); if (!user) throw new AppError('User not found', 404); return user; // TypeScript knows this is PublicUser, not null }

The type information flows from repository → service → controller. TypeScript catches mismatches at every boundary.


Incrementally Migrating JavaScript to TypeScript

You do not need to convert everything at once. The incremental approach:

Step 1: Add TypeScript without breaking anything

json
// tsconfig.json — start permissive { "compilerOptions": { "allowJs": true, // allow .js files "checkJs": false, // don't type-check .js files yet "strict": false, // start loose, tighten later "outDir": "./dist" } }

Step 2: Convert files one by one

Rename .js to .ts. Fix errors. Commit. Move to the next file.

Start with:

  1. types/models.ts — define your domain types first
  2. repositories/ — add return types to DB functions
  3. services/ — add input/output types
  4. controllers/ — type req, res, next
  5. Last: index.ts, middleware, routes

Step 3: Tighten the config progressively

json
// After most files converted { "compilerOptions": { "strict": true, "checkJs": false, // still allow JS where needed "allowJs": false // once all files are .ts } }

Step 4: Enable noUncheckedIndexedAccess

This is the last setting to add — it causes the most friction but catches real bugs:

typescript
const arr = [1, 2, 3]; const first = arr[0]; // With noUncheckedIndexedAccess: number | undefined if (first !== undefined) { console.log(first * 2); // TypeScript is sure it's a number here }

Path Aliases

Avoid ../../../repositories/users.repository with path aliases:

json
// tsconfig.json { "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"], "@types/*": ["src/types/*"], "@services/*": ["src/services/*"], "@repositories/*": ["src/repositories/*"] } } }
typescript
// Before import * as usersRepo from '../../../repositories/users.repository.js'; // After import * as usersRepo from '@repositories/users.repository.js';

For ts-node to resolve these, also install:

bash
npm install -D tsconfig-paths
json
// nodemon.json { "exec": "ts-node -r tsconfig-paths/register src/index.ts" }

Summary

  • "strict": true is non-negotiable. It enables the checks that make TypeScript worth using.
  • module: "NodeNext" with moduleResolution: "NodeNext" for correct ESM + CJS interop in Node.js.
  • Type your domain models in src/types/models.ts and import them everywhere — single source of truth.
  • Augment Express.Request in src/types/express.d.ts to type req.user, req.requestId, and any other middleware-added properties.
  • Use interface for object shapes, type for unions and computed types. Both are fine — consistency matters more than which you pick.
  • Utility types (Omit, Pick, Partial, Readonly, ReturnType) reduce duplication and keep types in sync with their source.
  • Migrate incrementallyallowJs: true lets TypeScript and JavaScript coexist. Convert file by file starting with the types and data access layers.

Next: input validation and error handling — replacing manual if (!name) checks with Zod schemas and building the error pipeline that makes every handler clean.

Discussion