tsconfig.json, typing Express handlers and middleware, interfaces vs types, generics, ts-node for dev, tsc for prod — and migrating a JavaScript project incrementally.
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
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
Install type definitions for your libraries:
bash
tsconfig.json: The Settings That Matter
json
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
text
Update package.json scripts:
json
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:
typescript
Typing Express Handlers
Express's built-in types are usable but loose. Here is the correct pattern:
typescript
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
After this, req.user is fully typed in every handler — no casting needed:
typescript
Interfaces vs Types
TypeScript checks that your data has the right shape, not that it wears the right name tag — like a bouncer who only checks you're holding a valid ticket stub, not which box office printed it. An interface Dog and a type Dog describing identical fields are interchangeable to the type checker; it never asks which keyword built the shape.
Both define object shapes. The differences that matter in practice:
typescript
Practical rule: use interface for object shapes (models, DTOs, service inputs). Use type for unions, intersections, and computed types (Omit, Pick, Partial).
unknown vs any: A Deliberate Safety Choice
Both types accept any value, but only one keeps the compiler on your side:
typescript
any opts a value out of type checking entirely — it's a trapdoor, not a type. unknown says "this could be anything, so prove what it is before you use it." The compiler forces a narrowing check (typeof, instanceof, a type guard) before it lets you call a method or access a property.
The most common place this shows up in an Express codebase is catch (err: unknown), which you'll use throughout P-4's error handling:
typescript
With catch (err: any) (the old default), err.message compiles even though err could be a string, a PrismaClientKnownRequestError, or anything else something throws. With err: unknown, TypeScript forces you to check what you actually caught before touching it — which is exactly what the error handler in P-4 does, branching on instanceof ZodError, instanceof AppError, instanceof Prisma.PrismaClientKnownRequestError, and so on.
Utility Types You Use Daily
typescript
Generics: Writing Flexible Typed Code
Generics let you write functions and types that work with any type while preserving type information:
typescript
Discriminated Unions: Narrowing by a Common Tag
The ApiResponse<T> type defined above is a discriminated union — a union where every member shares a literal-valued field (here, success) that TypeScript can use to figure out which branch you're in:
typescript
Once you check response.success, TypeScript eliminates the other branch from the type entirely inside that block — response.error is not even a valid property access in the if branch, and response.data is not valid in the else. This is the same pattern you'll lean on constantly with Zod's safeParse result, which is itself a discriminated union tagged on success:
typescript
Naming this pattern matters because it's easy to write the less safe version instead — checking a boolean and then reaching for a property that isn't guaranteed on both branches (e.g. an optional data?: T alongside an optional error?: string on the same type, rather than two variants of a union). A discriminated union makes invalid states like "both data and error are set" unrepresentable, not just unlikely.
The satisfies Operator (TS 4.9+)
satisfies checks that a value conforms to a type — without widening the value's inferred type the way a : annotation does. It's now the idiomatic way to type a config or environment object where you still want the literal values, not the general type, to flow through:
typescript
Compare the three options for the same object:
No annotation at all: config.env is inferred as string and a typo like 'produciton' compiles fine — nothing checks it against Env.
: { port: number; env: Env; logLevel: string }: catches the typo, but now config.env's type is the full Env union everywhere it's used — you lose the fact that this config object is specifically 'production'.
satisfies { ... }: catches the same typo, and config.env keeps its literal type ('production'), because satisfies only validates the shape — it doesn't change what TypeScript infers the variable's type to be.
This is exactly the shape of problem the env config object built in P-6 solves: you want process.env.NODE_ENV validated against a known set of values and you want the resulting config's fields to stay as narrow and specific as what was actually parsed, not widened into their general types. satisfies gets you both at once, which is why it's replaced the : Type annotation for this use case in most codebases written after TS 4.9.
Typing Service and Repository Layers
typescript
typescript
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
Step 2: Convert files one by one
Rename .js to .ts. Fix errors. Commit. Move to the next file.
Start with:
types/models.ts — define your domain types first
repositories/ — add return types to DB functions
services/ — add input/output types
controllers/ — type req, res, next
Last: index.ts, middleware, routes
Step 3: Tighten the config progressively
json
Step 4: Enable noUncheckedIndexedAccess
This is the last setting to add — it causes the most friction but catches real bugs:
typescript
Production story: A payments settlement service was migrated to TypeScript file by file, following exactly the order this section describes — types, then repositories, then services. The team turned on strict immediately but left noUncheckedIndexedAccess for "later," since it's the noisiest setting to enable and floods a large codebase with new errors on day one. Months later, when someone finally flipped it on, the compiler flagged a line that had been shipping for two years: const amount = transactions[0].amount;, written on the assumption that a merchant's settlement window always contained at least one transaction. It usually did — until a quiet merchant had a day with zero transactions. transactions[0] was undefined, .amount on it was also undefined, and undefined * exchangeRate silently evaluated to NaN, which got written straight into that day's reconciliation report with no exception ever thrown. noUncheckedIndexedAccess retypes transactions[0] as Transaction | undefined, forcing every call site to prove the array isn't empty before touching .amount — precisely the check that had been missing for two years.
Path Aliases
Avoid ../../../repositories/users.repository with path aliases:
json
typescript
tsconfig-paths/register is the common answer for resolving these — but it only patches Node's CommonJS Module._resolveFilename, and this project's tsconfig.json is set to NodeNext/--esm, which uses Node's native ESM resolver instead. Under native ESM, tsconfig-paths/register silently does nothing and path aliases won't resolve. For a NodeNext project like this one, resolve aliases at build time instead:
bash
json
tsc-alias runs after tsc and rewrites the @repositories/...-style imports in the compiled .js output back into real relative paths, which Node's ESM resolver can then load without any extra loader flags. (If you're on a CommonJS project instead of NodeNext, tsconfig-paths/register is the right tool — just not here.)
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 incrementally — allowJs: 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.
Knowledge Check
When migrating a Node.js project to TypeScript, which compiler option allows TypeScript and JavaScript files to coexist without strictly type-checking the JS files?
What is the primary practical difference between interface and type in TypeScript?
Which TypeScript utility type would you use to create a new type representing a User model without the passwordHash field?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
// JavaScript — what does this function expect?asyncfunctioncreateOrder({ userId, items, couponCode }){// ...}// TypeScript — the contract is explicit and verifiedinterfaceCreateOrderInput{userId: number;items:Array<{productId: number; quantity: number }>; couponCode?: string;// optional — the ? makes it clear}asyncfunctioncreateOrder(input:CreateOrderInput):Promise<Order>{// ...}
npminstall-D @types/express @types/bcrypt @types/jsonwebtoken
# Prisma generates its own types — no @types needed
{"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"]}
exportconstgetProfile=async(req: Request, res: Response, next: NextFunction)=>{// req.user is typed as { id: number; role: string } | undefinedif(!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);};
// interface — can be extended and merged (declaration merging)interfaceAnimal{ name:string;}interfaceDogextendsAnimal{ breed:string;}// Multiple declarations of the same interface mergeinterfaceRequest{ user?: User;}interfaceRequest{ requestId?:string;}// Result: Request has both user and requestId// type alias — more flexible, can represent unions and intersectionstypeID=number|string;typeStatus='active'|'banned';typeAdminUser= User &{ permissions:string[]};// intersectiontypeApiResponse<T>={ data:T; error:null}|{ data:null; error:string};
functionprocess(input:any){ input.toUpperCase();// compiles — even though this can crash at runtime}functionprocessSafe(input:unknown){ input.toUpperCase();// Error: Object is of type 'unknown'if(typeof input ==='string'){ input.toUpperCase();// fine — narrowed to string first}}
try{await usersService.create(input);}catch(err:unknown){// err.message would be a compile error here — err isn't known to have oneif(err instanceofAppError){// narrowed — err.message, err.statusCode are safe}}
interfaceUser{ id:number; name:string; email:string; passwordHash:string; role:'user'|'admin'; createdAt: Date;}// Omit — remove fieldstypePublicUser= Omit<User,'passwordHash'>;// Pick — keep only specific fieldstypeUserSummary= Pick<User,'id'|'name'>;// Partial — all fields optional (for update DTOs)typeUpdateUserInput= Partial<Pick<User,'name'|'email'|'role'>>;// Required — all fields requiredtypeCompleteUser= Required<User>;// Readonly — prevent mutationtypeImmutableUser= Readonly<User>;// Record — dictionary typetype RolePermissions = Record<User['role'],string[]>;// { user: string[]; admin: string[] }// ReturnType — infer the return type of a functionasyncfunctionfindUser(id:number):Promise<User |null>{/* ... */}typeFindUserResult= Awaited<ReturnType<typeof findUser>>;// → User | null// Parameters — infer function parameterstypeFindUserParams= Parameters<typeof findUser>;// → [id: number]// NonNullable — remove null and undefinedtypeDefiniteUser= NonNullable<User |null|undefined>;// → User
functionhandle<T>(response: ApiResponse<T>){if(response.success){console.log(response.data);// narrowed: this branch only has `data`}else{console.log(response.error, response.code);// narrowed: this branch only has `error`/`code`}}
const result = createUserSchema.safeParse(req.body);if(result.success){ result.data;// narrowed — safe to use, fully typed}else{ result.error;// narrowed — the other branch, a ZodError}
typeEnv='development'|'staging'|'production';const config ={ port:3000, env:'production', logLevel:'info',} satisfies { port:number; env: Env; logLevel:string};config.env;// inferred as the literal 'production', not the wider `Env`
// src/services/users.service.tsimport*as usersRepo from'../repositories/users.repository.js';import{ AppError }from'../errors/AppError.js';import{ PublicUser }from'../types/models.js';exportasyncfunctionfindById(id:number):Promise<PublicUser>{const user =await usersRepo.findById(id);if(!user)thrownewAppError('User not found',404);return user;// TypeScript knows this is PublicUser, not null}
// After most files converted{"compilerOptions":{"strict":true,"checkJs":false,// still allow JS where needed"allowJs":false// once all files are .ts}}
const arr =[1,2,3];const first = arr[0];// With noUncheckedIndexedAccess: number | undefinedif(first !==undefined){console.log(first *2);// TypeScript is sure it's a number here}