Chapter 40: Node.js TypeScript
0. Why TypeScript + Node.js in 2025–2026 is almost the default choice
- Almost all serious teams use TypeScript for Node.js backend
- New libraries (Prisma, tRPC, NestJS, Fastify, Hono, Zod, Drizzle, TypeBox…) are designed with TypeScript in mind
- Error detection moves from runtime → compile time
- Refactoring becomes much safer
- IDE experience is dramatically better (autocompletion, jump to definition, inline errors)
- Documentation is built-in — types serve as living documentation
Rule of thumb today:
If the project will live longer than 2–3 months or has more than 1 developer → use TypeScript If it’s a 1-day script or throwaway prototype → plain JavaScript is fine
1. Setting up a modern Node.js + TypeScript project (2025–2026 style)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
# 1. Create folder mkdir ts-api cd ts-api # 2. Initialize npm (with ESM) npm init -y # 3. Add TypeScript & essential dev tools npm install -D typescript @types/node tsx nodemon \ eslint prettier eslint-config-standard-with-typescript \ @typescript-eslint/parser @typescript-eslint/eslint-plugin # 4. Initialize TypeScript config npx tsc --init |
Now edit tsconfig.json — this is a realistic modern configuration
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "strict": true, "skipLibCheck": true, "outDir": "./dist", "rootDir": "./src", "sourceMap": true, "noEmitOnError": true, "declaration": true, "emitDecoratorMetadata": true, "experimentalDecorators": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] } |
package.json scripts — modern & practical
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
"scripts": { "dev": "tsx watch src/index.ts", "start": "node dist/index.js", "build": "tsc", "lint": "eslint . --ext .ts", "lint:fix": "eslint . --ext .ts --fix", "format": "prettier --write .", "typecheck": "tsc --noEmit" } |
2. Folder structure – what real Node.js + TypeScript projects look like
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
ts-api/ ├── src/ │ ├── config/ ← env, constants, config schemas │ │ └── index.ts │ ├── controllers/ ← route handlers │ │ └── user.controller.ts │ ├── services/ ← business logic │ │ └── user.service.ts │ ├── middleware/ ← auth, validation, error handling │ │ └── error.middleware.ts │ ├── routes/ ← route definitions │ │ └── user.routes.ts │ ├── types/ ← shared types, DTOs │ │ └── user.types.ts │ ├── utils/ ← helpers (logger, date, etc.) │ │ └── logger.ts │ └── index.ts ← entry point ├── prisma/ ← if using Prisma ├── .env ├── .env.example ├── tsconfig.json ├── package.json └── README.md |
This is a very common layered / feature-based structure.
3. First working example – Express API with TypeScript
1. Install dependencies
|
0 1 2 3 4 5 6 7 |
npm install express cors helmet npm install -D @types/express @types/cors @types/helmet |
2. src/index.ts – entry point
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 |
import express, { Express, Request, Response } from 'express'; import cors from 'cors'; import helmet from 'helmet'; import 'dotenv/config'; const app: Express = express(); const port = Number(process.env.PORT) || 5000; // Middleware app.use(helmet()); app.use(cors()); app.use(express.json()); // Health check app.get('/health', (req: Request, res: Response) => { res.json({ status: 'ok', uptime: process.uptime(), timestamp: new Date().toISOString(), nodeEnv: process.env.NODE_ENV || 'development' }); }); // Example route with types interface User { id: number; name: string; email: string; } const fakeUsers: User[] = [ { id: 1, name: 'Aman', email: 'aman@example.com' }, { id: 2, name: 'Priya', email: 'priya@example.com' } ]; app.get('/users', (req: Request, res: Response<User[]>) => { res.json(fakeUsers); }); app.get('/users/:id', (req: Request<{ id: string }>, res: Response<User | { error: string }>) => { const id = Number(req.params.id); const user = fakeUsers.find(u => u.id === id); if (!user) { return res.status(404).json({ error: 'User not found' }); } res.json(user); }); // Start server app.listen(port, () => { console.log(`🚀 Server running → http://localhost:${port}`); console.log(`Environment: ${process.env.NODE_ENV || 'development'}`); }); |
3. Run it
|
0 1 2 3 4 5 6 |
npm run dev |
Now you can hit:
- http://localhost:5000/health
- http://localhost:5000/users
- http://localhost:5000/users/1
TypeScript benefits you see immediately:
- req.params.id is typed as string — you must convert to number
- Response type is User | { error: string } — TypeScript knows what can be returned
- Autocompletion for req, res, app is excellent
- Errors are caught at compile time instead of runtime
4. Adding validation with Zod (very modern & popular)
|
0 1 2 3 4 5 6 |
npm install zod |
src/schemas/user.schema.ts
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
import { z } from 'zod'; export const createUserSchema = z.object({ name: z.string().min(2).max(50), email: z.string().email(), age: z.number().int().min(18).max(120).optional() }); export type CreateUserInput = z.infer<typeof createUserSchema>; |
Using it in controller
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
app.post('/users', async (req: Request<{}, {}, unknown>, res: Response) => { try { const data = createUserSchema.parse(req.body); // throws on invalid data // In real app → save to database const newUser = { id: Math.floor(Math.random() * 10000), ...data, createdAt: new Date().toISOString() }; res.status(201).json({ success: true, user: newUser }); } catch (err) { if (err instanceof z.ZodError) { return res.status(400).json({ success: false, errors: err.errors }); } res.status(500).json({ success: false, error: 'Internal server error' }); } }); |
Try it with curl
|
0 1 2 3 4 5 6 7 8 |
curl -X POST http://localhost:5000/users \ -H "Content-Type: application/json" \ -d '{"name":"Rahul","email":"invalid","age":15}' |
→ You get nice validation errors automatically
5. Error handling middleware (production must-have)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
// src/middleware/error.middleware.ts import { Request, Response, NextFunction } from 'express'; export function errorHandler( err: any, req: Request, res: Response, next: NextFunction ) { console.error('Error:', err); const status = err.status || 500; const message = err.message || 'Internal Server Error'; res.status(status).json({ success: false, error: message, // only in development ...(process.env.NODE_ENV !== 'production' && { stack: err.stack }) }); } |
Register it (last middleware)
|
0 1 2 3 4 5 6 |
app.use(errorHandler); |
6. Summary – Why TypeScript + Node.js feels so good in 2025–2026
| Feature / Benefit | Concrete example in Node.js code |
|---|---|
| Compile-time safety | req.params.id is string — must convert to number |
| Excellent autocompletion | res.json({ → sees all possible overloads |
| Refactoring safety | Rename userId → all places updated automatically |
| Living documentation | Hover over user → see full type shape |
| Better error messages | user.name → red squiggle if user can be null |
| Ecosystem alignment | Prisma, Zod, tRPC, Fastify, NestJS all TypeScript-first |
Which direction would you like to go next?
- Full project structure (controllers, services, middleware, routes, validation)
- Prisma + TypeScript integration (real DB example)
- Zod validation + DTO patterns
- Error handling & custom error classes
- Authentication (JWT + middleware)
- Testing with Vitest + TypeScript
Just tell me what you want to see next — I’ll continue with complete, realistic code examples. 😊
