TypeScript adds static typing to JavaScript, catching errors at compile time rather than runtime. Think of it as a spell-checker for your code: just as a spell-checker catches typos before you send an email, TypeScript catches type errors before your code runs in production.For Node.js applications, TypeScript provides better IDE support (autocomplete that actually knows what properties an object has), safer refactoring (rename a function and the compiler tells you every call site that needs updating), and self-documenting code (the types themselves serve as documentation that cannot drift out of date).
Setting up TypeScript in a Node.js project requires a few extra dependencies compared to plain JavaScript. The typescript package is the compiler itself. ts-node lets you run .ts files directly without a separate compile step (essential for development). The @types/* packages provide type definitions for libraries written in plain JavaScript — they are the Rosetta Stone that teaches TypeScript what express, node, and other libraries look like.
# Initialize projectmkdir my-ts-app && cd my-ts-appnpm init -y# Install dependencies# -D means devDependency -- TypeScript and type definitions are build tools,# not runtime code. They should not ship in your production node_modules.npm install typescript ts-node @types/node -Dnpm install expressnpm install @types/express -D# Initialize TypeScript config -- creates tsconfig.json with documented defaultsnpx tsc --init
{ "scripts": { // ts-node-dev watches for file changes and restarts automatically. // --respawn: restart on crash instead of exiting. // --transpile-only: skip type checking for faster restarts (your IDE handles type checking). "dev": "ts-node-dev --respawn --transpile-only src/server.ts", "build": "tsc", // Compile TypeScript to JavaScript in ./dist "start": "node dist/server.js", // Run the compiled JavaScript in production "lint": "eslint src/**/*.ts", "test": "jest" }}
Node.js tip: In production, always run the compiled JavaScript (node dist/server.js), never ts-node directly. ts-node adds startup overhead and memory usage from the TypeScript compiler. Your CI/CD pipeline should run npm run build and deploy only the dist/ folder.
TypeScript’s type system builds on JavaScript’s existing types but makes them explicit and enforced. In plain JavaScript, a variable can silently change from a number to a string to an object — TypeScript prevents that. Think of types as contracts: when you declare age: number, you are promising that age will always be a number, and the compiler holds you to it.
// Primitive types -- TypeScript can often infer these automatically,// but explicit annotations serve as documentation for other developers.const name: string = 'John';const age: number = 30;const isActive: boolean = true;// Arrays -- two equivalent syntaxes, choose one for consistencyconst numbers: number[] = [1, 2, 3]; // Preferred in most codebasesconst names: Array<string> = ['Alice', 'Bob']; // Generic syntax (same result)// Inline object type -- fine for one-off use, but use an interface for reusable shapesconst user: { name: string; age: number } = { name: 'John', age: 30};// Union types -- the variable can be ONE of the listed types.// This is incredibly useful for IDs (sometimes strings, sometimes numbers)// and for function parameters that accept multiple formats.let id: string | number = 1;id = 'abc'; // Also valid -- TypeScript tracks the current type through narrowing// Type aliases -- give a name to a type expression for reuse and readabilitytype ID = string | number;// Optional properties -- the ? means the field can be undefinedinterface User { id: ID; name: string; email?: string; // Optional -- may or may not be present}// Function types -- annotate parameters AND return type.// The return type annotation is optional (TypeScript can infer it),// but explicit return types catch mistakes where you accidentally return the wrong thing.const add = (a: number, b: number): number => a + b;// Async functions always return a Promise wrapping the actual return typeconst fetchUser = async (id: string): Promise<User> => { // ...};// Void means the function does not return a valueconst logMessage = (msg: string): void => { console.log(msg);};
TypeScript offers two ways to define the shape of data: interface and type. The difference is mostly stylistic for simple objects, but there are real differences. Interfaces are extendable (you can add fields to an existing interface by declaring it again — this is called “declaration merging”), while types are better for unions, intersections, and computed types. The Node.js/Express ecosystem convention is to use interface for object shapes and type for everything else.
// Interface for objects -- describes "what shape does this data have?"interface User { id: string; email: string; name: string; createdAt: Date;}// Extending interfaces -- Admin has all User fields plus its owninterface Admin extends User { role: 'admin'; permissions: string[];}// Type for unions -- a value can be one of several optionstype UserRole = 'user' | 'admin' | 'moderator';// Intersection type -- combines two types into one (User AND role)type UserWithRole = User & { role: UserRole;};// Generic interfaces -- the T is a placeholder that gets filled in at usage time.// This is like a template: one definition, many concrete types.interface ApiResponse<T> { success: boolean; data: T; error?: string; // The ? means this field is optional}// Usage -- T becomes User[] here, so data is typed as User[]const response: ApiResponse<User[]> = { success: true, data: users};
// src/server.tsimport express, { Application } from 'express';import cors from 'cors';import helmet from 'helmet';import { errorHandler } from './middleware/errorHandler';import userRoutes from './routes/userRoutes';const app: Application = express();app.use(helmet());app.use(cors());app.use(express.json());app.use('/api/users', userRoutes);app.use(errorHandler);const PORT = process.env.PORT || 3000;app.listen(PORT, () => { console.log(`Server running on port ${PORT}`);});export default app;
One of the most powerful aspects of TypeScript with Express is the ability to type your request parameters, body, and response. Express’s Request type accepts generic parameters: Request<Params, ResBody, ReqBody, Query>. By filling these in, your handler gets autocomplete and type checking for req.params, req.body, and res.json().
// src/types/express.d.ts// This "declaration merging" file extends Express's built-in Request type// to include a user property. Without this, TypeScript does not know// about req.user and would flag it as an error.import { User } from './user';declare global { namespace Express { interface Request { user?: User; } }}// src/controllers/userController.tsimport { Request, Response, NextFunction } from 'express';import { User, CreateUserDTO, UpdateUserDTO } from '../types/user';import * as userService from '../services/userService';// Response<{ data: User[] }> tells TypeScript that res.json() expects// an object with a data property containing a User array.// If you try to send something else, the compiler catches it.export const getUsers = async ( req: Request, res: Response<{ data: User[] }>, next: NextFunction): Promise<void> => { try { const users = await userService.findAll(); res.json({ data: users }); } catch (error) { next(error); }};// Request<{ id: string }> types req.params -- TypeScript now knows// req.params.id exists and is a string. Typos like req.params.userId// are caught at compile time, not discovered in production.export const getUser = async ( req: Request<{ id: string }>, res: Response<{ data: User }>, next: NextFunction): Promise<void> => { try { const user = await userService.findById(req.params.id); if (!user) { res.status(404).json({ error: 'User not found' } as any); return; } res.json({ data: user }); } catch (error) { next(error); }};// Request<{}, {}, CreateUserDTO> -- the third generic parameter types req.body.// This ensures req.body.email, req.body.password, req.body.name all have// the types defined in CreateUserDTO with full autocomplete support.export const createUser = async ( req: Request<{}, {}, CreateUserDTO>, res: Response<{ data: User }>, next: NextFunction): Promise<void> => { try { const user = await userService.create(req.body); res.status(201).json({ data: user }); } catch (error) { next(error); }};
Here is a subtlety that trips up TypeScript beginners: TypeScript types exist only at compile time. They are completely erased when your code runs. This means TypeScript cannot validate data that arrives at runtime — like HTTP request bodies, environment variables, or API responses from external services. The data could be anything.Zod bridges this gap. It lets you define a schema that validates data at runtime AND automatically infers a TypeScript type from that schema. One definition, both compile-time types and runtime validation. No duplication, no drift between your types and your validation logic.
npm install zod
// src/schemas/userSchema.tsimport { z } from 'zod';export const createUserSchema = z.object({ body: z.object({ email: z.string().email('Invalid email'), password: z.string().min(8, 'Password must be at least 8 characters'), name: z.string().min(2).max(100) })});export const updateUserSchema = z.object({ params: z.object({ id: z.string().uuid() }), body: z.object({ name: z.string().min(2).max(100).optional(), email: z.string().email().optional() })});// Infer types from schemas -- this is the key insight: the TypeScript type// is DERIVED from the runtime schema, so they can never go out of sync.// Change the schema, and the type updates automatically.export type CreateUserInput = z.infer<typeof createUserSchema>['body'];export type UpdateUserInput = z.infer<typeof updateUserSchema>['body'];// Validation middlewareimport { AnyZodObject, ZodError } from 'zod';export const validate = (schema: AnyZodObject) => async (req: Request, res: Response, next: NextFunction) => { try { await schema.parseAsync({ body: req.body, query: req.query, params: req.params }); next(); } catch (error) { if (error instanceof ZodError) { res.status(400).json({ success: false, errors: error.errors.map(e => ({ path: e.path.join('.'), message: e.message })) }); } else { next(error); } } };
TypeScript makes error handling more robust by letting you define typed error hierarchies. The public readonly modifiers in the constructor are a TypeScript shorthand that simultaneously declares the property on the class and assigns the constructor argument to it — avoiding the repetitive this.statusCode = statusCode pattern.
Prisma is the ideal database tool for TypeScript projects because it generates a fully-typed client from your schema file. Every query, every result, every relation is type-checked at compile time. If you rename a column in your schema and forget to update a query, TypeScript catches it immediately. This level of type safety is simply not possible with raw SQL or loosely-typed ORMs.
TypeScript shines in authentication middleware because it catches common mistakes like accessing properties that do not exist on the decoded token. The as JwtPayload type assertion below tells TypeScript the shape of the decoded token — if you later change the token structure, the types will guide you to update every consumer.
// src/middleware/auth.tsimport { Request, Response, NextFunction } from 'express';import jwt from 'jsonwebtoken';import { UnauthorizedError } from '../utils/errors';// Define the exact shape of your JWT payload.// This interface is the contract between token creation and token consumption.interface JwtPayload { userId: string; role: string;}export const auth = async ( req: Request, res: Response, next: NextFunction): Promise<void> => { try { const token = req.headers.authorization?.replace('Bearer ', ''); if (!token) { throw new UnauthorizedError('No token provided'); } // The ! (non-null assertion) tells TypeScript "I know this is defined." // This is safe only because we validate required env vars at startup (see env.ts). // Without that validation, this could crash at runtime with "undefined" as the secret. const decoded = jwt.verify( token, process.env.JWT_SECRET! ) as JwtPayload; req.user = { id: decoded.userId, role: decoded.role } as any; next(); } catch (error) { if (error instanceof jwt.JsonWebTokenError) { next(new UnauthorizedError('Invalid token')); } else { next(error); } }};export const authorize = (...roles: string[]) => { return (req: Request, res: Response, next: NextFunction): void => { if (!req.user) { next(new UnauthorizedError()); return; } if (!roles.includes(req.user.role)) { next(new UnauthorizedError('Insufficient permissions')); return; } next(); };};
Testing TypeScript code with Jest requires a small amount of configuration. Install ts-jest to let Jest understand .ts files, or use @swc/jest for faster compilation. The key benefit: your test code is also type-checked, so typos in test assertions are caught at compile time rather than producing confusing test failures.
A well-organized TypeScript project separates concerns into clearly-named directories. This structure mirrors the MVC (Model-View-Controller) pattern adapted for APIs: schemas handle input validation, services contain business logic, controllers handle HTTP concerns, and routes wire everything together. The types/ directory holds custom type definitions, and utils/ contains shared helpers.