Steve Kinney

Full-Stack TypeScript

Adding Types to Express Responses

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:

  1. Consistent API response format across all endpoints
  2. Type safety for both success and error responses
  3. Clear separation between success and error handling
  4. 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:

  1. All errors are properly typed
  2. Error responses follow a consistent format
  3. Different error types result in appropriate HTTP status codes
  4. 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:

  1. Declare the property in the Express namespace
  2. Use a custom interface that extends Request
  3. 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:

  1. Add the missing properties
  2. Make the property optional in the interface
  3. 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:

  1. Use an async handler wrapper
  2. Return the Promise explicitly
  3. 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
});

Last modified on .