TypeScript Patterns for Robust Production APIs: DTOs, Validation, and Safe Error Handling
Learn essential TypeScript patterns for building production-ready APIs, focusing on Data Transfer Objects (DTOs), robust data validation, and consistent, safe error handling strategies.
Building reliable and maintainable APIs is crucial for any modern application. When working with Node.js and other JavaScript runtimes, TypeScript offers a powerful layer of type safety that significantly enhances developer experience and reduces runtime errors. At Stacks Horizon, we leverage TypeScript extensively in our production APIs, and over time, we've adopted several key patterns that ensure our services are robust, predictable, and easy to evolve.
This article will dive into three fundamental TypeScript patterns we use daily: Data Transfer Objects (DTOs) for clear API contracts, comprehensive validation to ensure data integrity, and safe, consistent error handling.
1. Data Transfer Objects (DTOs): Defining Clear API Contracts
DTOs are objects that define the shape of data being sent over the network, either as requests (input DTOs) or responses (output DTOs). They act as explicit contracts for your API, making it immediately clear what data is expected and what data will be returned. This dramatically improves readability and maintainability, especially in larger teams or projects.
In TypeScript, DTOs are typically defined using interface or type.
Input DTO Example: Creating a User
Let's say we have an endpoint for creating a new user. We'd define an input DTO for the request body:
// src/dtos/user.dto.ts
interface CreateUserDto {
username: string;
email: string;
password?: string; // Optional field
age: number;
}
interface UpdateUserDto {
username?: string;
email?: string;
age?: number;
}
Output DTO Example: User Response
For an API response, you often want to return a subset of data or transform it. An output DTO helps define this structure, preventing sensitive information (like hashed passwords) from accidentally being exposed.
// src/dtos/user.dto.ts (continued)
interface UserResponseDto {
id: string;
username: string;
email: string;
createdAt: Date;
updatedAt: Date;
}
By explicitly defining these DTOs, your API's intentions become crystal clear, and TypeScript's static analysis ensures that your code adheres to these contracts.
2. Robust Validation: Ensuring Data Integrity
While TypeScript provides static type checking at compile time, it doesn't perform runtime validation. This means that even if your code expects a string, an API client could send a number or null. Robust runtime validation is essential to protect your application from malformed data, security vulnerabilities, and unexpected behavior.
We often use libraries like Zod for schema validation. Zod is type-first, meaning it infers TypeScript types directly from your validation schemas, reducing boilerplate and ensuring consistency.
Zod Validation Example
Let's extend our CreateUserDto with Zod validation:
// src/schemas/user.schema.ts
import { z } from 'zod';
export const createUserSchema = z.object({
username: z.string().min(3, 'Username must be at least 3 characters long'),
email: z.string().email('Invalid email format'),
password: z.string().min(8, 'Password must be at least 8 characters long').optional(),
age: z.number().int().positive('Age must be a positive integer').min(18, 'Must be at least 18 years old'),
});
// Infer the TypeScript type from the schema
export type CreateUserDto = z.infer<typeof createUserSchema>;
Now, in your API route handler, you can easily validate incoming data:
// src/controllers/user.controller.ts
import { Request, Response, NextFunction } from 'express';
import { createUserSchema, CreateUserDto } from '../schemas/user.schema';
export const createUser = (req: Request, res: Response, next: NextFunction) => {
try {
// Validate and parse the request body
const newUser: CreateUserDto = createUserSchema.parse(req.body);
// If validation passes, proceed with creating the user
// ... logic to save user to database ...
res.status(201).json({ message: 'User created successfully', user: newUser });
} catch (error) {
// Zod throws an error if validation fails
next(error); // Pass to error handling middleware
}
};
This approach ensures that only valid data reaches your business logic, preventing a wide range of potential issues.
3. Safe Error Handling: Graceful Failure
Even with the best validation, errors will occur – database failures, external service outages, or unexpected internal conditions. A robust API needs a consistent and safe way to handle and communicate these errors to clients without exposing sensitive internal details.
Custom Error Classes
To standardize error types and provide more context, we create custom error classes. This allows us to differentiate between various types of application errors (e.g., NotFoundError, ValidationError, UnauthorizedError).
// src/utils/errors.ts
export class AppError extends Error {
statusCode: number;
isOperational: boolean;
constructor(message: string, statusCode: number = 500) {
super(message);
this.statusCode = statusCode;
this.isOperational = true; // Marks errors we expect and handle gracefully
Error.captureStackTrace(this, this.constructor);
}
}
export class NotFoundError extends AppError {
constructor(message: string = 'Resource not found') {
super(message, 404);
}
}
export class ValidationError extends AppError {
constructor(message: string = 'Validation failed') {
super(message, 400);
}
}
Centralized Error Handling Middleware
For Node.js applications using frameworks like Express, a global error handling middleware is an excellent way to catch all errors and send standardized responses.
// src/middleware/error.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../utils/errors';
import { ZodError } from 'zod';
const sendErrorDevelopment = (err: AppError | Error | ZodError, res: Response) => {
res.status(err instanceof AppError ? err.statusCode : 500).json({
status: 'error',
message: err.message,
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined,
// Include detailed Zod errors in development
...(err instanceof ZodError && { issues: err.errors })
});
};
const sendErrorProduction = (err: AppError | Error, res: Response) => {
// Operational, trusted error: send message to client
if (err instanceof AppError && err.isOperational) {
res.status(err.statusCode).json({
status: 'error',
message: err.message,
});
} else {
// Programming or other unknown error: don't leak error details
console.error('UNEXPECTED ERROR 💥', err);
res.status(500).json({
status: 'error',
message: 'Something went wrong!',
});
}
};
export const errorHandler = (
err: Error | AppError | ZodError,
req: Request,
res: Response,
next: NextFunction
) => {
if (err instanceof ZodError) {
// Handle Zod validation errors specifically
const validationError = new ValidationError('Invalid input data', 400);
// In development, we might want to expose more details from ZodError
return sendErrorDevelopment(err, res);
}
if (process.env.NODE_ENV === 'development') {
sendErrorDevelopment(err, res);
} else {
sendErrorProduction(err, res);
}
};
And then, apply it in your main application file:
// src/app.ts
import express from 'express';
import { errorHandler } from './middleware/error.middleware';
import { createUser } from './controllers/user.controller';
const app = express();
app.use(express.json());
app.post('/users', createUser);
// Catch-all for undefined routes
app.all('*', (req, res, next) => {
next(new NotFoundError(`Can't find ${req.originalUrl} on this server!`));
});
// Global error handling middleware
app.use(errorHandler);
export default app;
This setup ensures that all errors, whether from validation, business logic, or unexpected system failures, are caught and handled gracefully, providing consistent and informative (but safe) responses to API clients.
Conclusion
Adopting these TypeScript patterns – DTOs for clear contracts, robust validation with libraries like Zod, and a centralized, safe error handling strategy – significantly elevates the quality and reliability of your production APIs. They not only harness the power of TypeScript's static analysis but also enforce best practices for runtime data integrity and graceful error recovery. By investing in these patterns, you'll build APIs that are more resilient, easier to maintain, and a pleasure for other developers to consume.
What are your go-to TypeScript patterns for API development? Share your insights in the comments below!
Comments
Share your thoughts on this article.
Loading comments…
