Express.js TypeScript APIs — Complete Backend Guide
In this tutorial, you will learn about Express.js TypeScript APIs. We cover key concepts, practical examples, and best practices to help you master this topic.
TypeScript transforms Express.js development by adding type safety to request handlers, middleware, and route parameters, catching errors at compile time before they reach production.
What You'll Learn
- Typed Express.js routes and handlers
- Request validation with Zod
- Middleware typing patterns
- Error handling middleware
- Production-ready API structure
Why It Matters
Express.js without TypeScript relies on manual inspection for request bodies, query params, and response shapes. A single mistyped property can crash your API. TypeScript catches these issues during development, reducing runtime errors by up to 40% in backend applications.
Real-World Use
The Durga Antivirus Pro license verification API handles millions of requests daily using Express.js with TypeScript. Typed request handlers ensure that license keys, device IDs, and response payloads are always in the correct format — no surprises in production.
Learning Path
flowchart LR A[Node Setup] --> B[Express APIs] B --> C[Next.js] B --> D[NestJS] B --> E[You Are Here] C --> F[Database Access]
Setting Up an Express Project
Create a new project and install dependencies:
mkdir my-api && cd my-api
npm init -y
npm install express
npm install --save-dev typescript @types/express @types/node tsx
The @types/express package provides type definitions for Express's Request, Response, and NextFunction objects.
Basic Typed Server
import express, { Request, Response } from 'express';
const app = express();
const port = 3000;
app.get('/health', (_req: Request, res: Response) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
app.listen(port, () => {
console.log(`Server running at http://localhost:${port}`);
});
Expected output:
{ "status": "ok", "timestamp": "2026-06-28T12:00:00.000Z" }
Typed Request Handlers
Express's Request type is generic — you can specify body, params, and query types:
import { Request, Response } from 'express';
interface CreateUserBody {
name: string;
email: string;
age?: number;
}
interface UserParams {
id: string;
}
app.post('/users', (req: Request<{}, {}, CreateUserBody>, res: Response) => {
const { name, email } = req.body;
// req.body is typed as CreateUserBody
console.log(`Creating user: ${name} (${email})`);
res.status(201).json({ id: '123', name, email });
});
app.get('/users/:id', (req: Request<UserParams>, res: Response) => {
// req.params.id is typed as string
res.json({ id: req.params.id, name: 'John Doe' });
});
Why this matters: Without generics, req.body is any. A typo like req.body.emial compiles fine and crashes at runtime. With typed handlers, the compiler catches it immediately.
Request Validation with Zod
Raw type assertions don't validate at runtime — you need a validation library. Zod integrates perfectly with TypeScript:
npm install zod
import { z } from 'zod';
const CreateUserSchema = z.object({
name: z.string().min(2).max(50),
email: z.string().email(),
age: z.number().int().positive().optional(),
});
type CreateUserInput = z.infer<typeof CreateUserSchema>;
app.post('/users', (req: Request, res: Response) => {
const result = CreateUserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'Validation failed',
details: result.error.flatten(),
});
}
// result.data is typed as CreateUserInput
const user: CreateUserInput = result.data;
res.status(201).json({ id: '456', ...user });
});
Expected output (invalid input):
{
"error": "Validation failed",
"details": {
"fieldErrors": {
"email": ["Invalid email"]
}
}
}
The z.infer<typeof Schema> pattern gives you both runtime validation and compile-time type safety from a single source of truth.
Typed Middleware
Middleware functions in Express receive Request, Response, and NextFunction. Create typed middleware for authentication, logging, and error handling:
import { Request, Response, NextFunction } from 'express';
// Extend the Request type to add custom properties
declare global {
namespace Express {
interface Request {
userId?: string;
}
}
}
function authMiddleware(req: Request, res: Response, next: NextFunction): void {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
// Verify token (simplified)
req.userId = 'user_123';
next();
}
// Now use it in routes
app.get('/profile', authMiddleware, (req: Request, res: Response) => {
res.json({ userId: req.userId, name: 'Protected User' });
});
Global augmentation is the standard pattern for adding custom properties to Express's Request. The declare global { namespace Express { interface Request { ... } } } block merges your property into the existing type.
Error Handling Middleware
Express error handlers have four parameters — TypeScript helps you get the signature right:
import { Request, Response, NextFunction } from 'express';
class AppError extends Error {
constructor(
public statusCode: number,
public message: string,
public isOperational = true
) {
super(message);
this.name = 'AppError';
}
}
function errorHandler(
err: Error,
_req: Request,
res: Response,
_next: NextFunction
): void {
if (err instanceof AppError) {
res.status(err.statusCode).json({
error: err.message,
...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
});
} else {
console.error('Unhandled error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
// Register error handler LAST
app.use(errorHandler);
// Usage in routes
app.get('/fail', (_req: Request, _res: Response) => {
throw new AppError(400, 'Bad request');
});
The four-parameter signature (err, req, res, next) is what tells Express this is an error handler. TypeScript ensures you don't accidentally omit a parameter.
Production API Structure
For real applications, organize your code into modules:
src/
routes/
users.ts
auth.ts
middleware/
auth.ts
validate.ts
services/
user-service.ts
types/
index.ts
app.ts
server.ts
// src/types/index.ts
export interface User {
id: string;
name: string;
email: string;
createdAt: Date;
}
export interface CreateUserRequest {
name: string;
email: string;
}
// src/services/user-service.ts
import { User, CreateUserRequest } from '../types';
const users: User[] = [];
export function createUser(data: CreateUserRequest): User {
const user: User = {
id: String(users.length + 1),
...data,
createdAt: new Date(),
};
users.push(user);
return user;
}
export function getUserById(id: string): User | undefined {
return users.find((u) => u.id === id);
}
// src/routes/users.ts
import { Router, Request, Response } from 'express';
import { createUser, getUserById } from '../services/user-service';
const router = Router();
router.post('/', (req: Request, res: Response) => {
const user = createUser(req.body);
res.status(201).json(user);
});
router.get('/:id', (req: Request, res: Response) => {
const user = getUserById(req.params.id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json(user);
});
export default router;
// src/app.ts
import express from 'express';
import userRoutes from './routes/users';
import { errorHandler } from './middleware/error';
const app = express();
app.use(express.json());
app.use('/users', userRoutes);
app.use(errorHandler);
export default app;
Common Mistakes
1. Forgetting to parse JSON body
Without app.use(express.json()), req.body is undefined. TypeScript won't warn you because req.body is typed as any by default.
2. Not validating request data
TypeScript types are compile-time only. A Request<{}, {}, CreateUserBody> assertion doesn't validate at runtime — use Zod or similar.
3. Wrong error handler signature
Express error handlers must have exactly four parameters. Omitting next makes it a regular middleware that won't catch errors.
4. Not handling async errors
Express doesn't catch promise rejections automatically. Wrap async handlers:
import { Request, Response, NextFunction } from 'express';
function asyncHandler(fn: (req: Request, res: Response, next: NextFunction) => Promise<void>) {
return (req: Request, res: Response, next: NextFunction) => {
fn(req, res, next).catch(next);
};
}
5. Overusing any for request types
Typing req.body as any defeats the purpose of TypeScript. Always define interfaces or use Zod inference.
6. Forgetting CORS configuration
Browser-based clients need CORS headers. Install @types/cors and configure it properly.
7. Not using environment variables
Hardcoding config values leads to security issues. Use dotenv with typed config objects.
Practice Questions
What generics does
Request<Params, ResBody, ReqBody, Query>accept? The four type parameters define params, response body, request body, and query string types respectively.How do you add a custom property like
req.userto Express's Request? Use global augmentation:declare global { namespace Express { interface Request { user?: User } } }.What's the difference between
z.inferand defining a separate interface?z.inferderives the type from the Zod schema, keeping validation and types in sync. Separate interfaces can drift out of sync.Why does Express error middleware need four parameters? Express identifies error handlers by their function signature length. Three parameters = regular middleware, four = error handler.
How do you handle async errors without wrapping every route? Create an
asyncHandlerwrapper function, or use a library likeexpress-async-errors.
Challenge
Build a typed Express API for a todo app with CRUD operations, Zod validation, error handling middleware, and typed service layer.
FAQ
Mini Project
Build a contact book API with Express and TypeScript:
- POST /contacts — create contact (name, email, phone, Zod validated)
- GET /contacts — list all contacts (typed response)
- GET /contacts/:id — get contact by ID
- PUT /contacts/:id — update contact
- DELETE /contacts/:id — delete contact
- Error middleware — consistent error responses
- Auth middleware — simple token check on protected routes
Implement a typed in-memory data store with full CRUD operations.
What's Next
Now that you can build typed Express APIs, you're ready to explore the TypeScript ecosystem deeper. Next, learn how to build full-stack applications with {{< ref "45-nextjs" >}}, or dive into structured backend architecture with {{< ref "46-nestjs" >}}.
You can also explore {{< ref "47-database-access" >}} to connect your Express API to a real database.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro