Chapter 10: Error Handling
Authored by syscook.dev
What is Error Handling in TypeScript?
Error handling in TypeScript is the process of anticipating, detecting, and responding to errors that occur during program execution. TypeScript provides strong type safety for error handling, making it easier to catch and handle different types of errors at compile time and runtime.
Key Concepts:
- Exception Handling: Using try-catch blocks to handle errors
- Custom Error Classes: Creating specific error types
- Error Boundaries: Patterns for containing and handling errors
- Type-Safe Error Handling: Leveraging TypeScript's type system
- Async Error Handling: Managing errors in asynchronous operations
- Error Recovery: Strategies for recovering from errors
Why Use Proper Error Handling?
1. Application Stability
Proper error handling prevents applications from crashing and provides graceful degradation.
// Without proper error handling - application crashes
function divideNumbers(a: number, b: number): number {
return a / b; // Can cause division by zero
}
// With proper error handling - graceful handling
function divideNumbersSafe(a: number, b: number): number {
if (b === 0) {
throw new Error("Division by zero is not allowed");
}
return a / b;
}
// Usage with error handling
function handleDivision(a: number, b: number) {
try {
const result = divideNumbersSafe(a, b);
console.log(`${a} / ${b} = ${result}`);
} catch (error) {
console.error("Error:", error.message);
console.log("Using default value: 0");
}
}
handleDivision(10, 2);
handleDivision(10, 0);
Output:
10 / 2 = 5
Error: Division by zero is not allowed
Using default value: 0
2. Better User Experience
Error handling provides meaningful feedback to users and developers.
// User-friendly error handling
class ValidationError extends Error {
constructor(
message: string,
public field: string,
public value: any
) {
super(message);
this.name = 'ValidationError';
}
}
function validateUser(user: any): void {
if (!user.name || typeof user.name !== 'string') {
throw new ValidationError(
"Name is required and must be a string",
"name",
user.name
);
}
if (!user.email || typeof user.email !== 'string') {
throw new ValidationError(
"Email is required and must be a string",
"email",
user.email
);
}
if (!user.email.includes('@')) {
throw new ValidationError(
"Email must be a valid email address",
"email",
user.email
);
}
}
function createUser(userData: any) {
try {
validateUser(userData);
console.log("User created successfully:", userData);
return { success: true, user: userData };
} catch (error) {
if (error instanceof ValidationError) {
console.error(`Validation failed for field '${error.field}': ${error.message}`);
console.error(`Invalid value: ${error.value}`);
return { success: false, error: error.message, field: error.field };
}
throw error; // Re-throw unexpected errors
}
}
// Usage
const validUser = { name: "John", email: "[email protected]" };
const invalidUser = { name: "", email: "invalid-email" };
console.log("Valid user:", createUser(validUser));
console.log("Invalid user:", createUser(invalidUser));
Output:
User created successfully: { name: 'John', email: '[email protected]' }
Valid user: { success: true, user: { name: 'John', email: '[email protected]' } }
Validation failed for field 'name': Name is required and must be a string
Invalid value:
Invalid user: { success: false, error: 'Name is required and must be a string', field: 'name' }
How to Use Error Handling?
1. Basic Error Handling
Try-Catch Blocks
// Basic try-catch
function riskyOperation(value: number): number {
if (value < 0) {
throw new Error("Value must be positive");
}
if (value > 100) {
throw new Error("Value must be less than or equal to 100");
}
return Math.sqrt(value);
}
function handleRiskyOperation(value: number) {
try {
const result = riskyOperation(value);
console.log(`Square root of ${value} is ${result}`);
} catch (error) {
console.error(`Error processing ${value}:`, error.message);
}
}
// Usage
handleRiskyOperation(16); // Success
handleRiskyOperation(-5); // Error
handleRiskyOperation(150); // Error
Output:
Square root of 16 is 4
Error processing -5: Value must be positive
Error processing 150: Value must be less than or equal to 100
Finally Blocks
// Try-catch-finally
function processFile(filename: string): string {
console.log(`Opening file: ${filename}`);
try {
// Simulate file processing
if (filename.includes('error')) {
throw new Error("File processing failed");
}
const content = `Content of ${filename}`;
console.log("File processed successfully");
return content;
} catch (error) {
console.error("Error processing file:", error.message);
throw error;
} finally {
console.log(`Closing file: ${filename}`);
}
}
// Usage
try {
const content1 = processFile("document.txt");
console.log("Content 1:", content1);
} catch (error) {
console.error("Failed to process file 1");
}
console.log("---");
try {
const content2 = processFile("error.txt");
console.log("Content 2:", content2);
} catch (error) {
console.error("Failed to process file 2");
}
Output:
Opening file: document.txt
File processed successfully
Closing file: document.txt
Content 1: Content of document.txt
---
Opening file: error.txt
Error processing file: File processing failed
Closing file: error.txt
Failed to process file 2
2. Custom Error Classes
Basic Custom Error Classes
// Base error class
abstract class AppError extends Error {
abstract readonly statusCode: number;
abstract readonly isOperational: boolean;
constructor(message: string) {
super(message);
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}
// Specific error classes
class ValidationError extends AppError {
readonly statusCode = 400;
readonly isOperational = true;
constructor(message: string, public field: string) {
super(message);
}
}
class NotFoundError extends AppError {
readonly statusCode = 404;
readonly isOperational = true;
constructor(resource: string) {
super(`${resource} not found`);
}
}
class InternalServerError extends AppError {
readonly statusCode = 500;
readonly isOperational = false;
constructor(message: string = "Internal server error") {
super(message);
}
}
// Usage
function findUser(userId: number): { id: number; name: string } {
if (userId <= 0) {
throw new ValidationError("User ID must be positive", "userId");
}
if (userId > 1000) {
throw new NotFoundError("User");
}
return { id: userId, name: `User ${userId}` };
}
function handleUserLookup(userId: number) {
try {
const user = findUser(userId);
console.log("User found:", user);
} catch (error) {
if (error instanceof ValidationError) {
console.error(`Validation error: ${error.message} (field: ${error.field})`);
} else if (error instanceof NotFoundError) {
console.error(`Not found error: ${error.message}`);
} else {
console.error("Unexpected error:", error.message);
}
}
}
// Usage
handleUserLookup(1); // Success
handleUserLookup(-1); // Validation error
handleUserLookup(2000); // Not found error
Output:
User found: { id: 1, name: 'User 1' }
Validation error: User ID must be positive (field: userId)
Not found error: User not found
Advanced Error Classes with Context
// Advanced error class with context
class ApiError extends AppError {
readonly statusCode: number;
readonly isOperational = true;
constructor(
message: string,
statusCode: number = 500,
public endpoint?: string,
public requestId?: string
) {
super(message);
this.statusCode = statusCode;
}
toJSON() {
return {
name: this.name,
message: this.message,
statusCode: this.statusCode,
endpoint: this.endpoint,
requestId: this.requestId,
stack: this.stack
};
}
}
// Error factory
class ErrorFactory {
static createValidationError(field: string, value: any): ValidationError {
return new ValidationError(
`Invalid value for field '${field}': ${value}`,
field
);
}
static createApiError(
message: string,
statusCode: number,
endpoint?: string,
requestId?: string
): ApiError {
return new ApiError(message, statusCode, endpoint, requestId);
}
static createNotFoundError(resource: string, id?: string | number): NotFoundError {
const message = id ? `${resource} with ID ${id} not found` : `${resource} not found`;
return new NotFoundError(message);
}
}
// Usage
function simulateApiCall(endpoint: string, requestId: string) {
try {
if (endpoint.includes('error')) {
throw ErrorFactory.createApiError(
"Simulated API error",
500,
endpoint,
requestId
);
}
if (endpoint.includes('notfound')) {
throw ErrorFactory.createNotFoundError("Resource", "123");
}
return { success: true, data: "API response" };
} catch (error) {
if (error instanceof ApiError) {
console.error("API Error:", error.toJSON());
} else {
throw error;
}
}
}
simulateApiCall("/api/users", "req-123");
simulateApiCall("/api/error", "req-456");
simulateApiCall("/api/notfound", "req-789");
Output:
API Error: {
name: 'ApiError',
message: 'Simulated API error',
statusCode: 500,
endpoint: '/api/error',
requestId: 'req-456',
stack: '...'
}
3. Async Error Handling
Error Handling in Async Functions
// Async error handling
async function fetchUserData(userId: number): Promise<{ id: number; name: string }> {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
if (userId <= 0) {
throw new ValidationError("User ID must be positive", "userId");
}
if (userId === 404) {
throw new NotFoundError("User");
}
if (userId === 500) {
throw new InternalServerError("Database connection failed");
}
return { id: userId, name: `User ${userId}` };
}
// Error handling in async functions
async function handleUserData(userId: number) {
try {
const userData = await fetchUserData(userId);
console.log("User data:", userData);
return { success: true, data: userData };
} catch (error) {
if (error instanceof ValidationError) {
console.error("Validation error:", error.message);
return { success: false, error: "Invalid input", field: error.field };
} else if (error instanceof NotFoundError) {
console.error("Not found error:", error.message);
return { success: false, error: "User not found" };
} else if (error instanceof InternalServerError) {
console.error("Server error:", error.message);
return { success: false, error: "Server error" };
} else {
console.error("Unexpected error:", error.message);
return { success: false, error: "Unknown error" };
}
}
}
// Usage
async function demonstrateAsyncErrorHandling() {
const results = await Promise.allSettled([
handleUserData(1), // Success
handleUserData(-1), // Validation error
handleUserData(404), // Not found error
handleUserData(500) // Server error
]);
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`Result ${index + 1}:`, result.value);
} else {
console.log(`Result ${index + 1} failed:`, result.reason);
}
});
}
demonstrateAsyncErrorHandling();
Output:
User data: { id: 1, name: 'User 1' }
Validation error: User ID must be positive
Not found error: User not found
Server error: Database connection failed
Result 1: { success: true, data: { id: 1, name: 'User 1' } }
Result 2: { success: false, error: 'Invalid input', field: 'userId' }
Result 3: { success: false, error: 'User not found' }
Result 4: { success: false, error: 'Server error' }
Error Handling with Retry Logic
// Retry logic with error handling
class RetryableError extends Error {
constructor(message: string, public retryable: boolean = true) {
super(message);
this.name = 'RetryableError';
}
}
async function fetchDataWithRetry<T>(
fetchFn: () => Promise<T>,
maxRetries: number = 3,
delay: number = 1000
): Promise<T> {
let lastError: Error;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
console.log(`Attempt ${attempt}/${maxRetries}`);
const result = await fetchFn();
console.log(`Success on attempt ${attempt}`);
return result;
} catch (error) {
lastError = error as Error;
console.log(`Attempt ${attempt} failed:`, error.message);
if (error instanceof RetryableError && !error.retryable) {
console.log("Error is not retryable, stopping");
throw error;
}
if (attempt < maxRetries) {
console.log(`Retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
delay *= 2; // Exponential backoff
}
}
}
throw new Error(`Failed after ${maxRetries} attempts. Last error: ${lastError.message}`);
}
// Simulate flaky API
async function flakyApiCall(): Promise<string> {
await new Promise(resolve => setTimeout(resolve, 500));
const random = Math.random();
if (random < 0.3) {
throw new RetryableError("Temporary network error", true);
} else if (random < 0.5) {
throw new RetryableError("Permanent error", false);
} else if (random < 0.7) {
throw new Error("Unexpected error");
}
return "API data fetched successfully";
}
// Usage
async function demonstrateRetryLogic() {
try {
const result = await fetchDataWithRetry(flakyApiCall, 3, 1000);
console.log("Final result:", result);
} catch (error) {
console.error("All retries failed:", error.message);
}
}
demonstrateRetryLogic();
Output:
Attempt 1/3
Attempt 1 failed: Temporary network error
Retrying in 1000ms...
Attempt 2/3
Attempt 2 failed: Temporary network error
Retrying in 2000ms...
Attempt 3/3
Success on attempt 3
Final result: API data fetched successfully
4. Error Boundaries and Recovery
Error Boundary Pattern
// Error boundary for containing errors
class ErrorBoundary {
private static instance: ErrorBoundary;
private errorHandlers: Map<string, (error: Error) => void> = new Map();
static getInstance(): ErrorBoundary {
if (!ErrorBoundary.instance) {
ErrorBoundary.instance = new ErrorBoundary();
}
return ErrorBoundary.instance;
}
registerHandler(component: string, handler: (error: Error) => void): void {
this.errorHandlers.set(component, handler);
}
handleError(component: string, error: Error): void {
const handler = this.errorHandlers.get(component);
if (handler) {
handler(error);
} else {
console.error(`Unhandled error in ${component}:`, error);
}
}
}
// Component with error boundary
class DataProcessor {
private errorBoundary = ErrorBoundary.getInstance();
constructor() {
this.errorBoundary.registerHandler('DataProcessor', this.handleError.bind(this));
}
private handleError(error: Error): void {
console.error("DataProcessor error:", error.message);
// Implement recovery logic
this.recover();
}
private recover(): void {
console.log("Attempting to recover...");
// Implement recovery logic
}
async processData(data: any[]): Promise<any[]> {
try {
return data.map(item => {
if (typeof item !== 'object') {
throw new Error(`Invalid data item: ${item}`);
}
return { ...item, processed: true };
});
} catch (error) {
this.errorBoundary.handleError('DataProcessor', error as Error);
throw error;
}
}
}
// Usage
const processor = new DataProcessor();
const validData = [{ id: 1, name: "John" }, { id: 2, name: "Jane" }];
const invalidData = [{ id: 1, name: "John" }, "invalid", { id: 3, name: "Bob" }];
processor.processData(validData)
.then(result => console.log("Valid data processed:", result))
.catch(error => console.error("Processing failed:", error.message));
processor.processData(invalidData)
.then(result => console.log("Invalid data processed:", result))
.catch(error => console.error("Processing failed:", error.message));
Output:
Valid data processed: [
{ id: 1, name: 'John', processed: true },
{ id: 2, name: 'Jane', processed: true }
]
DataProcessor error: Invalid data item: invalid
Attempting to recover...
Processing failed: Invalid data item: invalid
Practical Examples
1. API Error Handling System
// Comprehensive API error handling system
interface ApiErrorResponse {
error: {
code: string;
message: string;
details?: any;
};
status: number;
timestamp: string;
}
class ApiErrorHandler {
static handleError(error: any): ApiErrorResponse {
if (error instanceof ValidationError) {
return {
error: {
code: 'VALIDATION_ERROR',
message: error.message,
details: { field: error.field }
},
status: 400,
timestamp: new Date().toISOString()
};
}
if (error instanceof NotFoundError) {
return {
error: {
code: 'NOT_FOUND',
message: error.message
},
status: 404,
timestamp: new Date().toISOString()
};
}
if (error instanceof ApiError) {
return {
error: {
code: 'API_ERROR',
message: error.message,
details: { endpoint: error.endpoint, requestId: error.requestId }
},
status: error.statusCode,
timestamp: new Date().toISOString()
};
}
// Unknown error
return {
error: {
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred'
},
status: 500,
timestamp: new Date().toISOString()
};
}
}
// API service with error handling
class ApiService {
async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
try {
const response = await fetch(endpoint, options);
if (!response.ok) {
if (response.status === 404) {
throw new NotFoundError("Resource");
}
if (response.status === 400) {
throw new ValidationError("Invalid request", "body");
}
throw new ApiError(
`HTTP ${response.status}: ${response.statusText}`,
response.status,
endpoint
);
}
return await response.json();
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new ApiError(
`Network error: ${error.message}`,
500,
endpoint
);
}
}
}
// Usage
async function demonstrateApiErrorHandling() {
const apiService = new ApiService();
try {
const data = await apiService.request('/api/users');
console.log("API data:", data);
} catch (error) {
const errorResponse = ApiErrorHandler.handleError(error);
console.log("Error response:", errorResponse);
}
}
// Note: This would work with a real API
// demonstrateApiErrorHandling();
2. Form Validation with Error Handling
// Form validation with comprehensive error handling
interface FormField {
value: any;
error?: string;
touched: boolean;
}
interface FormData {
[key: string]: FormField;
}
class FormValidator {
private errors: Map<string, string> = new Map();
validateField(fieldName: string, value: any, rules: ValidationRule[]): string | null {
for (const rule of rules) {
try {
rule.validate(value);
} catch (error) {
if (error instanceof ValidationError) {
this.errors.set(fieldName, error.message);
return error.message;
}
throw error;
}
}
this.errors.delete(fieldName);
return null;
}
validateForm(formData: FormData, validationRules: Record<string, ValidationRule[]>): FormData {
const validatedForm = { ...formData };
for (const [fieldName, field] of Object.entries(formData)) {
const rules = validationRules[fieldName] || [];
const error = this.validateField(fieldName, field.value, rules);
validatedForm[fieldName] = {
...field,
error
};
}
return validatedForm;
}
getErrors(): Map<string, string> {
return new Map(this.errors);
}
hasErrors(): boolean {
return this.errors.size > 0;
}
}
// Validation rules
abstract class ValidationRule {
abstract validate(value: any): void;
}
class RequiredRule extends ValidationRule {
validate(value: any): void {
if (value === null || value === undefined || value === '') {
throw new ValidationError("This field is required", "value");
}
}
}
class EmailRule extends ValidationRule {
validate(value: any): void {
if (typeof value !== 'string') {
throw new ValidationError("Email must be a string", "email");
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
throw new ValidationError("Email must be a valid email address", "email");
}
}
}
class MinLengthRule extends ValidationRule {
constructor(private minLength: number) {
super();
}
validate(value: any): void {
if (typeof value !== 'string') {
throw new ValidationError("Value must be a string", "value");
}
if (value.length < this.minLength) {
throw new ValidationError(`Value must be at least ${this.minLength} characters long`, "value");
}
}
}
// Usage
const validator = new FormValidator();
const formData: FormData = {
name: { value: "John", touched: true },
email: { value: "[email protected]", touched: true },
password: { value: "123", touched: true }
};
const validationRules = {
name: [new RequiredRule(), new MinLengthRule(2)],
email: [new RequiredRule(), new EmailRule()],
password: [new RequiredRule(), new MinLengthRule(8)]
};
const validatedForm = validator.validateForm(formData, validationRules);
console.log("Validated form:");
Object.entries(validatedForm).forEach(([fieldName, field]) => {
console.log(`${fieldName}:`, {
value: field.value,
error: field.error || "No error",
touched: field.touched
});
});
console.log("Has errors:", validator.hasErrors());
console.log("All errors:", Object.fromEntries(validator.getErrors()));
Output:
Validated form:
name: { value: 'John', error: 'No error', touched: true }
email: { value: '[email protected]', error: 'No error', touched: true }
password: { value: '123', error: 'Value must be at least 8 characters long', touched: true }
Has errors: true
All errors: { password: 'Value must be at least 8 characters long' }
Best Practices
1. Use Specific Error Types
// Good: Specific error types
class ValidationError extends Error {
constructor(message: string, public field: string) {
super(message);
this.name = 'ValidationError';
}
}
// Avoid: Generic error handling
function badErrorHandling(error: any) {
console.error("Error:", error);
}
2. Handle Errors at the Right Level
// Good: Handle errors at appropriate levels
async function processUserData(userId: number) {
try {
const user = await fetchUser(userId);
return processUser(user);
} catch (error) {
if (error instanceof ValidationError) {
// Handle validation errors locally
return { error: error.message, field: error.field };
}
// Re-throw unexpected errors
throw error;
}
}
3. Provide Meaningful Error Messages
// Good: Meaningful error messages
throw new ValidationError(
"Email address must contain '@' symbol",
"email"
);
// Avoid: Generic error messages
throw new Error("Invalid input");
4. Implement Error Recovery
// Good: Implement error recovery
async function fetchDataWithFallback() {
try {
return await fetchFromPrimarySource();
} catch (error) {
console.warn("Primary source failed, trying fallback");
return await fetchFromFallbackSource();
}
}
Common Pitfalls and Solutions
1. Swallowing Errors
// ❌ Problem: Swallowing errors
try {
riskyOperation();
} catch (error) {
// Error is swallowed
}
// ✅ Solution: Handle or re-throw errors
try {
riskyOperation();
} catch (error) {
console.error("Operation failed:", error);
// Either handle the error or re-throw it
throw error;
}
2. Generic Error Handling
// ❌ Problem: Generic error handling
catch (error) {
console.error("Something went wrong");
}
// ✅ Solution: Specific error handling
catch (error) {
if (error instanceof ValidationError) {
handleValidationError(error);
} else if (error instanceof NetworkError) {
handleNetworkError(error);
} else {
handleUnexpectedError(error);
}
}
3. Not Preserving Stack Traces
// ❌ Problem: Losing stack trace
catch (error) {
throw new Error("New error message");
}
// ✅ Solution: Preserve original error
catch (error) {
throw new Error(`New error message: ${error.message}`);
}
Conclusion
Error handling in TypeScript provides powerful tools for creating robust applications. By understanding:
- What error handling is and its key concepts
- Why it's essential for application stability and user experience
- How to implement try-catch blocks, custom error classes, and async error handling
You can create applications that gracefully handle errors and provide meaningful feedback. TypeScript's type system ensures that error handling is type-safe, making it easier to catch and handle different types of errors at compile time.
Next Steps
- Practice creating custom error classes
- Implement error handling in async operations
- Explore error recovery strategies
- Move on to Chapter 11: Testing with TypeScript
This tutorial is part of the TypeScript Mastery series by syscook.dev