
TypeScript is now the default. Here’s how to migrate an existing Express app without breaking anything — one file at a time.
PUBLISH METADATA
- Tags: TypeScript, Node.js, JavaScript, Web Development, Express
- Description: A practical step-by-step guide to migrating an existing Node.js Express app to TypeScript in 2026 — using incremental migration, allowJs, proper type definitions, and common patterns for routes, middleware, and database models.
- Suggested publish date: Week 4 — Monday
- Suggested cover image: JavaScript logo with arrow pointing to TypeScript logo. Node.js + Express branding. Blue theme.
Introduction
TypeScript adoption exploded in 2025–2026. It’s now the default at most engineering teams, and job listings increasingly require it. If your Node.js backend is still in plain JavaScript, this guide gets you there incrementally — no big rewrite required.
We’ll migrate a real Express app in stages, keeping it running at every step.
Step 1: Install TypeScript Dependencies

npm install --save-dev typescript ts-node ts-node-dev @types/node @types/express
# Generate tsconfig.json
npx tsc --init
Step 2: Configure tsconfig.json for Express
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"allowJs": true, // Key: allows JS files during migration
"checkJs": false, // Don't type-check JS files yet
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}allowJs: true is the secret to incremental migration. TypeScript compiles both .js and .ts files, so you can rename files one at a time.
Step 3: Update package.json Scripts
{
"scripts": {
"dev": "ts-node-dev --respawn --transpile-only src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"type-check": "tsc --noEmit"
}
}Step 4: Rename and Type Your Entry Point First
// src/server.ts (was server.js)
import express from 'express'
import { json } from 'express'
import { userRoutes } from './routes/users'
import { productRoutes } from './routes/products'
import { errorHandler } from './middleware/error'
const app = express()
const PORT = process.env.PORT || 3000
app.use(json())
app.use('/api/users', userRoutes)
app.use('/api/products', productRoutes)
app.use(errorHandler)
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`)
})Step 5: Type Your Request and Response Objects

The most common pattern in Express TypeScript:
// types/express.d.ts — extend Express types
import { User } from '../models/user'
declare global {
namespace Express {
interface Request {
user?: User // Added by auth middleware
}
}
}// middleware/auth.ts
import { Request, Response, NextFunction } from 'express'
import { verifyToken } from '../lib/jwt'
export async function authMiddleware(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
const token = req.headers.authorization?.split(' ')[1]
if (!token) {
res.status(401).json({ error: 'No token provided' })
return
} try {
req.user = await verifyToken(token)
next()
} catch {
res.status(401).json({ error: 'Invalid token' })
}
}Step 6: Type Your Route Handlers
// routes/users.ts
import { Router, Request, Response } from 'express'
import { authMiddleware } from '../middleware/auth'
import { UserService } from '../services/user.service'
export const userRoutes = Router()
const userService = new UserService()
// Typed route handler
userRoutes.get('/me', authMiddleware, async (req: Request, res: Response) => {
try {
const user = await userService.findById(req.user!.id)
if (!user) {
res.status(404).json({ error: 'User not found' })
return
}
res.json({ user })
} catch (err) {
res.status(500).json({ error: 'Server error' })
}
})
Step 7: Type Your Data Models
// types/models.ts
export interface User {
id: string
email: string
name: string
role: 'admin' | 'user'
createdAt: Date
}
export interface CreateUserDto {
email: string
name: string
password: string
}export interface UpdateUserDto {
name?: string
email?: string
}// services/user.service.ts
import { User, CreateUserDto, UpdateUserDto } from '../types/models'
import { db } from '../lib/db'
import bcrypt from 'bcryptjs'
export class UserService {
async findById(id: string): Promise<User | null> {
return db.user.findUnique({ where: { id } })
} async create(dto: CreateUserDto): Promise<User> {
const passwordHash = await bcrypt.hash(dto.password, 12)
return db.user.create({
data: { email: dto.email, name: dto.name, passwordHash }
})
} async update(id: string, dto: UpdateUserDto): Promise<User> {
return db.user.update({ where: { id }, data: dto })
}
}Step 8: Add Zod for Runtime Validation
TypeScript types are compile-time only. Use Zod to validate at runtime:
// validators/user.validator.ts
import { z } from 'zod'
export const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(2).max(50),
password: z.string().min(8).regex(/[A-Z]/, 'Must contain uppercase')
.regex(/[0-9]/, 'Must contain number'),
})export type CreateUserInput = z.infer<typeof CreateUserSchema>
// Validation middleware
import { Request, Response, NextFunction } from 'express'
import { ZodSchema } from 'zod'
export function validate(schema: ZodSchema) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body)
if (!result.success) {
return res.status(400).json({ errors: result.error.flatten() })
}
req.body = result.data
next()
}
}// Usage
userRoutes.post('/', validate(CreateUserSchema), async (req, res) => {
const user = await userService.create(req.body) // Fully typed!
res.status(201).json({ user })
})
Migration Checklist

- TypeScript + ts-node-dev installed
- tsconfig.json with allowJs: true
- Entry point (server.ts) converted
- Express types extended for req.user
- All middleware typed with Request, Response, NextFunction
- All route handlers typed
- Service layer uses typed interfaces
- Zod validation on all POST/PUT endpoints
- npm run type-check passes with 0 errors
- allowJs: false (turn off when all files are .ts)
Conclusion
TypeScript migration doesn’t need to be scary. With allowJs: true, you convert one file at a time, keeping the app running throughout. Start with the entry point, then middleware, then routes, then services.
The payoff: your IDE will catch bugs before they reach production, onboarding new developers becomes faster, and refactoring becomes much safer.
#TypeScript #Nodejs #JavaScript #WebDevelopment #Express #Backend #Programming #SoftwareEngineering
I’m a Full-Stack Developer specializing in React, Next.js, and Node.js.
👉 Connect with me: https://www.linkedin.com/in/chiragmehta900/
