Just as we type request inputs, we should type response outputs to ensure API consistency.
interface UserResponse {
id: string;
username: string;
email: string;
createdAt: string;
}
app.get(
'/users/:id',
(req: Request<{ id: string }>, res: Response<UserResponse | { error: string }>) => {
try {
// Fetch user logic...
const user: UserResponse = {
id: '123',
username: 'johndoe',
email: 'john@example.com',
createdAt: new Date().toISOString(),
};
res.json(user);
} catch (error) {
// TypeScript ensures this matches the error shape we declared
res.status(404).json({ error: 'User not found' });
}
},
);
API Response Patterns
For consistent API responses, consider defining standard patterns:
// Define a standard API response structure
interface ApiResponse<T> {
success: boolean;
data?: T;
error?: {
message: string;
code: string;
details?: unknown;
};
}
// Create helper functions for consistent responses
function sendSuccess<T>(res: Response<ApiResponse<T>>, data: T, status = 200) {
return res.status(status).json({
success: true,
data,
});
}
function sendError(
res: Response<ApiResponse<never>>,
message: string,
code = 'INTERNAL_ERROR',
status = 500,
details?: unknown,
) {
return res.status(status).json({
success: false,
error: {
message,
code,
...(details && { details }),
},
});
}
// Example usage
app.get(
'/users/:id',
async (req: Request<{ id: string }>, res: Response<ApiResponse<UserResponse>>) => {
try {
const user = await findUser(req.params.id);
if (!user) {
return sendError(res, 'User not found', 'USER_NOT_FOUND', 404);
}
return sendSuccess(res, user);
} catch (error) {
return sendError(res, 'Failed to retrieve user', 'RETRIEVAL_ERROR', 500, error);
}
},
);
This pattern provides several benefits:
- Consistent API response format across all endpoints
- Type safety for both success and error responses
- Clear separation between success and error handling
- Reduced boilerplate through helper functions
Response Streaming and Complex Types
Express can send more than just JSON. For streaming responses or complex types, we need specialized handling:
// Streaming file response
app.get('/files/:id', (req: Request<{ id: string }>, res: Response) => {
// For streams, the Response generic type isn't as helpful
// because we're not using res.json()
const fileStream = getFileStream(req.params.id);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', 'attachment; filename="document.pdf"');
fileStream.pipe(res);
});
// For HTML responses
interface HtmlResponse {
html: string;
}
app.get('/page', (req: Request, res: Response<HtmlResponse>) => {
// This isn't ideal, as we're using res.send() with HTML
// but typing it as if it's JSON
res.send('<html><body><h1>Hello World</h1></body></html>');
});
For these cases, the Response generic type has limitations. A better approach is to use union types based on the content type:
// Define response types for different content types
type JsonResponse<T> = Response<T>;
type HtmlResponse = Response; // HTML responses don't benefit as much from typing
type StreamResponse = Response; // Same for streams
// Create more specific route handler types
interface RouteHandlers {
json<T, P = {}, Q = {}>(req: Request<P, {}, {}, Q>, res: JsonResponse<T>): void;
html(req: Request, res: HtmlResponse): void;
stream(req: Request, res: StreamResponse): void;
}
// Example usage
const handlers: RouteHandlers = {
json<UserResponse>(req, res) {
// Handle JSON response
res.json({ id: '123', username: 'johndoe' });
},
html(req, res) {
// Handle HTML response
res.send('<html><body><h1>Hello</h1></body></html>');
},
stream(req, res) {
// Handle stream response
getFileStream().pipe(res);
},
};
// Register routes
app.get('/api/users', handlers.json);
app.get('/page', handlers.html);
app.get('/download', handlers.stream);
This approach provides more clarity about the response type in your route handlers.
Advanced Typing Techniques: Power Tools
Now that we’ve covered the basics, let’s explore advanced typing techniques that make Express with TypeScript even more powerful.
Branded Types for Enhanced Safety
Sometimes basic types aren’t enough. For IDs, tokens, and other special strings, we can use branded types:
// Define branded types
type UserId = string & { readonly _brand: unique symbol };
type SessionToken = string & { readonly _brand: unique symbol };
// Create functions to safely create branded types
function createUserId(id: string): UserId {
return id as UserId;
}
function createSessionToken(token: string): SessionToken {
return token as SessionToken;
}
// Use in request and response types
interface GetUserRequest {
params: {
userId: UserId;
};
}
interface UserResponse {
id: UserId;
username: string;
email: string;
}
interface AuthResponse {
token: SessionToken;
user: UserResponse;
}
// Example usage
app.get('/users/:userId', (req: Request<GetUserRequest['params']>, res: Response<UserResponse>) => {
const rawUserId = req.params.userId;
// Convert string to branded type
const userId = createUserId(rawUserId);
// Now we have a type-safe userId that can't be confused with other string IDs
const user = getUserById(userId);
res.json(user);
});
app.post('/login', (req: Request, res: Response<AuthResponse>) => {
// Generate a session token
const token = createSessionToken(generateRandomToken());
// Get user
const userId = createUserId('123');
const user = getUserById(userId);
// Return typed response
res.json({
token,
user,
});
});
Branded types ensure that you don’t accidentally mix up different types of IDs or tokens, even though they’re all strings underneath.
Type-Safe Middleware Chains
Express middleware can be challenging to type correctly, especially when middleware adds properties to the request:
// Define middleware that adds user to request
interface RequestWithUser extends Request {
user: {
id: UserId;
roles: string[];
};
}
function attachUser(req: Request, res: Response, next: NextFunction): void {
// Authenticate and attach user
(req as RequestWithUser).user = {
id: createUserId('123'),
roles: ['user'],
};
next();
}
// Type guard middleware
function isAuthenticated(req: Request, res: Response, next: NextFunction): void {
if (!('user' in req)) {
return res.status(401).send('Unauthorized');
}
next();
}
// Create a type-safe middleware chain builder
function createProtectedRoute<
P = ParamsDictionary,
ResBody = any,
ReqBody = any,
ReqQuery = ParsedQs,
>(
handler: (
req: RequestWithUser & Request<P, ResBody, ReqBody, ReqQuery>,
res: Response<ResBody>,
next: NextFunction,
) => void,
) {
return [attachUser, isAuthenticated, handler as RequestHandler];
}
// Use it to define protected routes
app.get(
'/admin',
...createProtectedRoute<{}, { message: string }>((req, res) => {
// req.user is properly typed and guaranteed to exist
if (!req.user.roles.includes('admin')) {
return res.status(403).json({ message: 'Forbidden' });
}
res.json({ message: 'Welcome to admin area' });
}),
);
This pattern guarantees that req.user
exists and is properly typed in your route handler, with both compile-time and runtime checks.
Handling Async Route Handlers
Express doesn’t natively handle promises, which can lead to unhandled rejections. A type-safe wrapper helps:
// Define a wrapper for async route handlers
function asyncHandler<P = ParamsDictionary, ResBody = any, ReqBody = any, ReqQuery = ParsedQs>(
handler: (
req: Request<P, ResBody, ReqBody, ReqQuery>,
res: Response<ResBody>,
next: NextFunction,
) => Promise<void>,
): RequestHandler<P, ResBody, ReqBody, ReqQuery> {
return (req, res, next) => {
Promise.resolve(handler(req, res, next)).catch(next);
};
}
// Use it with async routes
app.get(
'/users/:id',
asyncHandler<{ id: string }, UserResponse>(async (req, res) => {
const user = await getUserById(req.params.id);
if (!user) {
return res.status(404).json({ error: 'User not found' } as any);
}
res.json(user);
}),
);
This pattern ensures that any errors in async handlers are properly passed to Express’s error handling middleware.
Integrated Error Handling: The Safety Net
Proper error handling is crucial for robust APIs. TypeScript helps ensure errors are handled consistently.
Typed Error Middleware
Express error middleware has a distinct signature:
// Define structured error types
interface AppError extends Error {
statusCode: number;
code: string;
details?: unknown;
}
// Create typed error classes
class NotFoundError extends Error implements AppError {
statusCode = 404;
code = 'NOT_FOUND';
constructor(message = 'Resource not found') {
super(message);
this.name = 'NotFoundError';
}
}
class ValidationError extends Error implements AppError {
statusCode = 400;
code = 'VALIDATION_ERROR';
details: unknown;
constructor(message = 'Validation failed', details?: unknown) {
super(message);
this.name = 'ValidationError';
this.details = details;
}
}
// Create a type-safe error handler
function errorHandler(err: Error, req: Request, res: Response, next: NextFunction): void {
console.error(err);
// Handle known error types
if ('statusCode' in err && 'code' in err) {
const appError = err as AppError;
return res.status(appError.statusCode).json({
error: {
message: appError.message,
code: appError.code,
...(appError.details && { details: appError.details }),
},
});
}
// Handle unknown errors
res.status(500).json({
error: {
message: 'Internal server error',
code: 'INTERNAL_ERROR',
},
});
}
// Register the error handler (must be last)
app.use(errorHandler);
// Usage in routes
app.get(
'/users/:id',
asyncHandler(async (req, res) => {
const user = await getUserById(req.params.id);
if (!user) {
throw new NotFoundError(`User with ID ${req.params.id} not found`);
}
res.json(user);
}),
);
app.post(
'/users',
asyncHandler(async (req, res) => {
try {
// Validation logic
const validatedData = validateUser(req.body);
// Create user
const user = await createUser(validatedData);
res.status(201).json(user);
} catch (error) {
if (error.name === 'ValidationError') {
throw new ValidationError('User validation failed', error.details);
}
throw error;
}
}),
);
This structured approach to error handling ensures that:
- All errors are properly typed
- Error responses follow a consistent format
- Different error types result in appropriate HTTP status codes
- Error details are preserved when needed
Global Error Types
For a consistent approach across your application, define error types in a central location:
// errors.ts
export enum ErrorCode {
NOT_FOUND = 'NOT_FOUND',
VALIDATION_ERROR = 'VALIDATION_ERROR',
UNAUTHORIZED = 'UNAUTHORIZED',
FORBIDDEN = 'FORBIDDEN',
INTERNAL_ERROR = 'INTERNAL_ERROR',
DATABASE_ERROR = 'DATABASE_ERROR',
}
export interface ErrorResponse {
error: {
message: string;
code: ErrorCode;
details?: unknown;
};
}
export class AppError extends Error {
statusCode: number;
code: ErrorCode;
details?: unknown;
constructor(message: string, code: ErrorCode, statusCode: number, details?: unknown) {
super(message);
this.name = 'AppError';
this.code = code;
this.statusCode = statusCode;
this.details = details;
}
}
export class NotFoundError extends AppError {
constructor(message = 'Resource not found', details?: unknown) {
super(message, ErrorCode.NOT_FOUND, 404, details);
this.name = 'NotFoundError';
}
}
export class ValidationError extends AppError {
constructor(message = 'Validation failed', details?: unknown) {
super(message, ErrorCode.VALIDATION_ERROR, 400, details);
this.name = 'ValidationError';
}
}
// Additional error classes...
Then use these types across your application:
import { NotFoundError, ValidationError, ErrorResponse } from './errors';
app.get(
'/users/:id',
(req: Request<{ id: string }>, res: Response<UserResponse | ErrorResponse>) => {
// Route implementation...
},
);
System Integration: Making TypeScript Work with Your Stack
Express applications rarely exist in isolation. They integrate with databases, external APIs, and frontend applications. TypeScript can help ensure these integrations are type-safe.
Database Integration
For database operations, consider using an ORM like TypeORM, Prisma, or Sequelize that provides TypeScript support:
// Using Prisma with TypeScript
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// The User type is automatically generated from your schema
async function getUserById(id: string) {
return prisma.user.findUnique({
where: { id },
});
}
app.get(
'/users/:id',
asyncHandler(async (req: Request<{ id: string }>, res: Response) => {
const user = await getUserById(req.params.id);
if (!user) {
throw new NotFoundError(`User with ID ${req.params.id} not found`);
}
// Prisma types match your database schema
res.json(user);
}),
);
External API Integration
When calling external APIs, define types for the expected responses:
// Define types for external API responses
interface GithubUser {
id: number;
login: string;
name: string;
email: string | null;
avatar_url: string;
}
// Type-safe API client
async function getGithubUser(username: string): Promise<GithubUser> {
const response = await fetch(`https://api.github.com/users/${username}`);
if (!response.ok) {
throw new Error(`GitHub API error: ${response.statusText}`);
}
return response.json() as Promise<GithubUser>;
}
// Use in a route
app.get(
'/github/:username',
asyncHandler(async (req: Request<{ username: string }>, res: Response) => {
try {
const githubUser = await getGithubUser(req.params.username);
// Transform to our API format
const user = {
id: githubUser.id.toString(),
username: githubUser.login,
name: githubUser.name,
email: githubUser.email,
avatarUrl: githubUser.avatar_url,
};
res.json(user);
} catch (error) {
if (error.message.includes('GitHub API error')) {
throw new NotFoundError(`GitHub user ${req.params.username} not found`);
}
throw error;
}
}),
);
Frontend Integration: API Contracts
For frontend applications consuming your API, create shared type definitions:
// shared/api-types.ts (used by both frontend and backend)
export interface User {
id: string;
username: string;
email: string;
createdAt: string;
}
export interface CreateUserRequest {
username: string;
email: string;
password: string;
}
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: {
message: string;
code: string;
details?: unknown;
};
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
pageSize: number;
hasMore: boolean;
}
// API endpoints type definitions
export interface ApiEndpoints {
'GET /users': {
query: {
page?: number;
limit?: number;
search?: string;
};
response: ApiResponse<PaginatedResponse<User>>;
};
'GET /users/:id': {
params: {
id: string;
};
response: ApiResponse<User>;
};
'POST /users': {
body: CreateUserRequest;
response: ApiResponse<User>;
};
// Define other endpoints...
}
Then use these shared types in both your Express backend and frontend code:
// Backend usage
import { ApiEndpoints, User, ApiResponse } from '../shared/api-types';
type GetUserParams = ApiEndpoints['GET /users/:id']['params'];
type GetUserResponse = ApiEndpoints['GET /users/:id']['response'];
app.get('/users/:id', (req: Request<GetUserParams>, res: Response<GetUserResponse>) => {
// Implementation...
});
// Frontend usage (e.g., with fetch or axios)
async function getUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
const data: ApiResponse<User> = await response.json();
if (!data.success || !data.data) {
throw new Error(data.error?.message || 'Failed to fetch user');
}
return data.data;
}
This shared type approach ensures that your API contract is consistently enforced across both frontend and backend.
Debugging and Troubleshooting: When Types Go Wrong
Even with TypeScript, you’ll occasionally encounter type-related issues. Here’s how to debug and solve them.
Common Type Errors and Solutions
Error: Property Does not Exist on Type
Property 'user' does not exist on type 'Request<ParamsDictionary, any, any, ParsedQs, Record<string, any>>'.
This often happens when you’re trying to access properties added by middleware. Solutions:
- Declare the property in the Express namespace
- Use a custom interface that extends Request
- Use type assertion (as a last resort)
Error: Type is not Assignable
Type '{ id: string; name: string; }' is not assignable to type 'User'.
Property 'email' is missing in type '{ id: string; name: string; }' but required in type 'User'.
This happens when your object doesn’t match the expected type. Solutions:
- Add the missing properties
- Make the property optional in the interface
- Use a partial type:
Partial<User>
Error: No Overload Matches This Call
No overload matches this call.
The last overload gave the following error.
Argument of type '(req: Request<{ id: string; }>, res: Response) => Promise<void>' is not assignable to parameter of type 'RequestHandler<...>'.
This often happens with async handlers. Solutions:
- Use an async handler wrapper
- Return the Promise explicitly
- Add proper error handling in the async function
Debugging Techniques
Type Inspection
Use Visual Studio Code’s hover information to inspect types:
// Hover over 'req' to see its inferred type
app.get('/users', (req, res) => {
// Check req's type
});
The typeof
Operator
Use typeof
to check runtime types:
app.post('/data', (req: Request, res: Response) => {
console.log(typeof req.body); // "object" if JSON, "string" if text, etc.
if (typeof req.body !== 'object' || req.body === null) {
return res.status(400).send('Expected object body');
}
});
Type Guards
Create custom type guards for runtime type checking:
// Define a type guard for User objects
function isUser(obj: any): obj is User {
return (
typeof obj === 'object' &&
obj !== null &&
typeof obj.id === 'string' &&
typeof obj.username === 'string' &&
typeof obj.email === 'string'
);
}
// Use it in a route
app.post('/users', (req: Request, res: Response) => {
if (!isUser(req.body)) {
return res.status(400).send('Invalid user data');
}
// req.body is now typed as User
const { id, username, email } = req.body;
});
Performance Considerations: Types without Compromise
TypeScript adds compile-time safety without runtime overhead, but there are still performance considerations.
Bundle Size and Startup Time
TypeScript is compiled away, but type checking code can add overhead:
// Runtime type checking can impact performance
function validateUser(data: unknown): User {
if (
typeof data !== 'object' ||
data === null ||
!('username' in data) ||
!('email' in data) ||
typeof data.username !== 'string' ||
typeof data.email !== 'string'
) {
throw new ValidationError('Invalid user data');
}
return data as User;
}
// More efficient with schema validation libraries
const userSchema = z.object({
username: z.string(),
email: z.string().email(),
});
function validateUser(data: unknown): User {
return userSchema.parse(data);
}
Schema validation libraries are optimized for performance, making them better than hand-rolled validation.
Memory Management
TypeScript doesn’t change JavaScript’s memory management, but it can help prevent memory leaks through proper typing:
// Without proper typing, this could leak
let userCache: any = {};
// With proper typing, it's clear what's stored
let userCache: Record<string, { user: User; timestamp: number }> = {};
// Even better, with a proper cache interface
interface Cache<T> {
get(key: string): T | undefined;
set(key: string, value: T, ttl?: number): void;
delete(key: string): boolean;
}
class UserCache implements Cache<User> {
private cache: Map<string, { value: User; expires: number }> = new Map();
get(key: string): User | undefined {
const item = this.cache.get(key);
if (!item) return undefined;
if (item.expires < Date.now()) {
this.cache.delete(key);
return undefined;
}
return item.value;
}
set(key: string, value: User, ttl = 3600000): void {
this.cache.set(key, {
value,
expires: Date.now() + ttl,
});
}
delete(key: string): boolean {
return this.cache.delete(key);
}
}
// Usage
const userCache = new UserCache();
app.get(
'/users/:id',
asyncHandler(async (req, res) => {
// Try cache first
const cachedUser = userCache.get(req.params.id);
if (cachedUser) {
return res.json(cachedUser);
}
// Fetch and cache
const user = await getUserById(req.params.id);
if (user) {
userCache.set(req.params.id, user);
}
res.json(user);
}),
);
Optimizing Validation Performance
For high-throughput APIs, validation can become a bottleneck:
// Optimize schema validation for performance
const userSchema = z.object({
username: z.string(),
email: z.string().email(),
});
// Precompile the validation schema
const validateUser = userSchema.parse;
// Use in route handlers
app.post('/users', (req: Request, res: Response) => {
try {
const user = validateUser(req.body);
// ...
} catch (error) {
// ...
}
});
For critical paths, consider selective validation:
// Validate only what you use
app.get('/users', (req: Request, res: Response) => {
const page = Number(req.query.page || '1');
const limit = Number(req.query.limit || '10');
// Only validate pagination parameters
if (isNaN(page) || page < 1) {
return res.status(400).send('Invalid page parameter');
}
if (isNaN(limit) || limit < 1 || limit > 100) {
return res.status(400).send('Invalid limit parameter');
}
// Proceed with validated parameters
});