Error Handling & Custom Errors
Introduction
Error handling is crucial for building robust applications. TypeScript enhances JavaScript's error handling with type safety, allowing you to create custom error classes and handle errors in a type-safe manner.
Proper error handling helps you:
- Identify and fix bugs faster
- Provide better user experiences
- Make debugging easier
- Write more maintainable code
Basic Error Handling
JavaScript Error Handling:
try {
// Code that might throw
throw new Error("Something went wrong");
} catch (error) {
// Handle error
console.error(error.message);
} finally {
// Always runs
console.log("Cleanup");
}
TypeScript Enhanced:
class ValidationError extends Error {
constructor(
message: string,
public field: string
) {
super(message);
this.name = "ValidationError";
}
}
try {
throw new ValidationError("Invalid email", "email");
} catch (error) {
if (error instanceof ValidationError) {
console.log(`${error.field}: ${error.message}`);
} else {
console.error("Unknown error");
}
}
Concepts
The Error Class
JavaScript's built-in Error class:
const error = new Error("Something went wrong");
console.log(error.name); // "Error"
console.log(error.message); // "Something went wrong"
console.log(error.stack); // Stack trace
Try-Catch-Finally
function divide(a: number, b: number): number {
try {
if (b === 0) {
throw new Error("Division by zero");
}
return a / b;
} catch (error) {
console.error("Error:", error);
return 0;
} finally {
console.log("Division attempt completed");
}
}
console.log(divide(10, 2)); // 5
console.log(divide(10, 0)); // 0 (with error logged)
Custom Error Classes
Create specific error types for different scenarios:
class ValidationError extends Error {
constructor(
message: string,
public field: string,
public value: any
) {
super(message);
this.name = "ValidationError";
// Maintains proper stack trace (V8 only)
if (Error.captureStackTrace) {
Error.captureStackTrace(this, ValidationError);
}
}
}
class NotFoundError extends Error {
constructor(
public resourceType: string,
public resourceId: string | number
) {
super(`${resourceType} with ID ${resourceId} not found`);
this.name = "NotFoundError";
}
}
class AuthenticationError extends Error {
constructor(message: string = "Authentication failed") {
super(message);
this.name = "AuthenticationError";
}
}
// Usage
function validateEmail(email: string): void {
if (!email.includes("@")) {
throw new ValidationError(
"Email must contain @",
"email",
email
);
}
}
try {
validateEmail("invalid-email");
} catch (error) {
if (error instanceof ValidationError) {
console.log(`Field: ${error.field}`);
console.log(`Value: ${error.value}`);
console.log(`Error: ${error.message}`);
}
}
Type Guards for Errors
function isError(error: unknown): error is Error {
return error instanceof Error;
}
function isValidationError(error: unknown): error is ValidationError {
return error instanceof ValidationError;
}
function handleError(error: unknown): void {
if (isValidationError(error)) {
console.error(`Validation failed for ${error.field}: ${error.message}`);
} else if (isError(error)) {
console.error(`Error: ${error.message}`);
} else {
console.error(`Unknown error: ${error}`);
}
}
Error Handling in Async Functions
async function fetchUser(id: number): Promise<User> {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new NotFoundError("User", id);
}
return await response.json();
} catch (error) {
if (error instanceof NotFoundError) {
console.error(error.message);
throw error; // Re-throw if needed
}
throw new Error("Failed to fetch user");
}
}
interface User {
id: number;
name: string;
}
Result Type Pattern
Instead of throwing, return success/failure:
- Note: In real codebases, teams usually standardise on either throwing exceptions or returning a
Resulttype. - Mixing both styles across an application can make error handling inconsistent, so most teams pick one approach per layer or service.
type Result<T, E = Error> =
| { success: true; value: T }
| { success: false; error: E };
function safeDivide(a: number, b: number): Result<number, string> {
if (b === 0) {
return {
success: false,
error: "Division by zero"
};
}
return {
success: true,
value: a / b
};
}
const result = safeDivide(10, 2);
if (result.success) {
console.log(result.value); // 5
} else {
console.error(result.error);
}
Examples
Form Validation System
class ValidationError extends Error {
constructor(
message: string,
public field: string,
public value: unknown
) {
super(message);
this.name = "ValidationError";
}
}
interface ValidationRule<T> {
validate: (value: T) => boolean;
message: string;
}
class Validator {
static required(fieldName: string): ValidationRule<any> {
return {
validate: (value) => value !== null && value !== undefined && value !== "",
message: `${fieldName} is required`
};
}
static minLength(min: number): ValidationRule<string> {
return {
validate: (value) => value.length >= min,
message: `Must be at least ${min} characters`
};
}
static maxLength(max: number): ValidationRule<string> {
return {
validate: (value) => value.length <= max,
message: `Must be at most ${max} characters`
};
}
static email(): ValidationRule<string> {
return {
validate: (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
message: "Must be a valid email"
};
}
static min(min: number): ValidationRule<number> {
return {
validate: (value) => value >= min,
message: `Must be at least ${min}`
};
}
static max(max: number): ValidationRule<number> {
return {
validate: (value) => value <= max,
message: `Must be at most ${max}`
};
}
}
function validateField<T>(
fieldName: string,
value: T,
rules: ValidationRule<T>[]
): void {
for (const rule of rules) {
if (!rule.validate(value)) {
throw new ValidationError(rule.message, fieldName, value);
}
}
}
// Usage
interface UserForm {
name: string;
email: string;
age: number;
}
function validateUserForm(form: UserForm): void {
try {
validateField("name", form.name, [
Validator.required("Name"),
Validator.minLength(2),
Validator.maxLength(50)
]);
validateField("email", form.email, [
Validator.required("Email"),
Validator.email()
]);
validateField("age", form.age, [
Validator.required("Age"),
Validator.min(18),
Validator.max(120)
]);
console.log("Form is valid!");
} catch (error) {
if (error instanceof ValidationError) {
console.error(`${error.field}: ${error.message}`);
}
}
}
// Test
validateUserForm({
name: "Alice",
email: "alice@example.com",
age: 25
});
validateUserForm({
name: "B", // Too short
email: "invalid-email",
age: 15 // Too young
});
HTTP Error Hierarchy
class HttpError extends Error {
constructor(
message: string,
public statusCode: number
) {
super(message);
this.name = "HttpError";
}
}
class BadRequestError extends HttpError {
constructor(message: string = "Bad Request") {
super(message, 400);
this.name = "BadRequestError";
}
}
class UnauthorizedError extends HttpError {
constructor(message: string = "Unauthorized") {
super(message, 401);
this.name = "UnauthorizedError";
}
}
class ForbiddenError extends HttpError {
constructor(message: string = "Forbidden") {
super(message, 403);
this.name = "ForbiddenError";
}
}
class NotFoundError extends HttpError {
constructor(message: string = "Not Found") {
super(message, 404);
this.name = "NotFoundError";
}
}
class InternalServerError extends HttpError {
constructor(message: string = "Internal Server Error") {
super(message, 500);
this.name = "InternalServerError";
}
}
// API Client
class ApiClient {
async request<T>(endpoint: string): Promise<T> {
try {
// Simulate API call
const statusCode = Math.floor(Math.random() * 500) + 200;
if (statusCode === 400) {
throw new BadRequestError("Invalid request parameters");
} else if (statusCode === 401) {
throw new UnauthorizedError("Please login");
} else if (statusCode === 403) {
throw new ForbiddenError("Access denied");
} else if (statusCode === 404) {
throw new NotFoundError("Resource not found");
} else if (statusCode >= 500) {
throw new InternalServerError("Server error");
}
return {} as T;
} catch (error) {
this.handleError(error);
throw error;
}
}
private handleError(error: unknown): void {
if (error instanceof UnauthorizedError) {
console.log("Redirecting to login...");
} else if (error instanceof HttpError) {
console.error(`HTTP ${error.statusCode}: ${error.message}`);
} else {
console.error("Unexpected error:", error);
}
}
}
// Usage
const client = new ApiClient();
async function fetchData() {
try {
await client.request("/api/data");
} catch (error) {
if (error instanceof HttpError) {
console.log(`Failed with status ${error.statusCode}`);
}
}
}
Retry Logic with Error Handling
class RetryError extends Error {
constructor(
message: string,
public attempts: number,
public lastError: Error
) {
super(message);
this.name = "RetryError";
}
}
async function retry<T>(
fn: () => Promise<T>,
maxAttempts: number = 3,
delayMs: number = 1000
): Promise<T> {
let lastError: Error;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (attempt === maxAttempts) {
throw new RetryError(
`Failed after ${maxAttempts} attempts`,
maxAttempts,
lastError
);
}
console.log(`Attempt ${attempt} failed, retrying in ${delayMs}ms...`);
await new Promise(resolve => setTimeout(resolve, delayMs * attempt));
}
}
throw new Error("Unexpected error in retry logic");
}
// Usage
async function unreliableOperation(): Promise<string> {
if (Math.random() > 0.7) {
return "Success!";
}
throw new Error("Operation failed");
}
async function example() {
try {
const result = await retry(unreliableOperation, 5, 500);
console.log(result);
} catch (error) {
if (error instanceof RetryError) {
console.error(`All ${error.attempts} attempts failed`);
console.error(`Last error: ${error.lastError.message}`);
}
}
}
Challenges
1. Custom NotFoundError
- Create NotFoundError class
- Include resource type and ID
- Test with different resources
💡 Show Answer
class NotFoundError extends Error {
constructor(
public resourceType: string,
public resourceId: string | number
) {
super(`${resourceType} with ID ${resourceId} not found`);
this.name = "NotFoundError";
}
}
function findUser(id: number): void {
throw new NotFoundError("User", id);
}
function findProduct(id: string): void {
throw new NotFoundError("Product", id);
}
try {
findUser(123);
} catch (error) {
if (error instanceof NotFoundError) {
console.log(`Resource: ${error.resourceType}`);
console.log(`ID: ${error.resourceId}`);
console.log(`Message: ${error.message}`);
}
}
2. Safe Division Function
- Function that doesn't throw
- Returns Result type
- Handle division by zero
💡 Show Answer
type Result<T, E> =
| { success: true; value: T }
| { success: false; error: E };
function safeDivide(a: number, b: number): Result<number, string> {
if (b === 0) {
return {
success: false,
error: "Cannot divide by zero"
};
}
if (!Number.isFinite(a) || !Number.isFinite(b)) {
return {
success: false,
error: "Arguments must be finite numbers"
};
}
return {
success: true,
value: a / b
};
}
// Usage
const result1 = safeDivide(10, 2);
if (result1.success) {
console.log(`Result: ${result1.value}`); // 5
}
const result2 = safeDivide(10, 0);
if (!result2.success) {
console.error(`Error: ${result2.error}`); // Cannot divide by zero
}
const result3 = safeDivide(10, Infinity);
if (!result3.success) {
console.error(`Error: ${result3.error}`);
}
3. Email Validator
- Throw ValidationError on invalid email
- Include field name and value
- Test with various inputs
💡 Show Answer
class ValidationError extends Error {
constructor(
message: string,
public field: string,
public value: any
) {
super(message);
this.name = "ValidationError";
}
}
function validateEmail(email: string): void {
if (!email || email.trim() === "") {
throw new ValidationError(
"Email cannot be empty",
"email",
email
);
}
if (!email.includes("@")) {
throw new ValidationError(
"Email must contain @ symbol",
"email",
email
);
}
if (!email.includes(".")) {
throw new ValidationError(
"Email must contain a domain",
"email",
email
);
}
const [local, domain] = email.split("@");
if (!local || local.length === 0) {
throw new ValidationError(
"Email must have a local part before @",
"email",
email
);
}
if (!domain || domain.length === 0) {
throw new ValidationError(
"Email must have a domain after @",
"email",
email
);
}
}
// Test cases
const testEmails = [
"valid@example.com",
"invalid",
"@example.com",
"user@",
"",
"no-at-sign.com"
];
testEmails.forEach(email => {
try {
validateEmail(email);
console.log(`✓ ${email} is valid`);
} catch (error) {
if (error instanceof ValidationError) {
console.log(`✗ ${email}: ${error.message}`);
}
}
});
4. Try-Catch Wrapper
- Create utility to wrap functions
- Return Result type instead of throwing
- Handle any errors
💡 Show Answer
type Result<T, E = Error> =
| { success: true; value: T }
| { success: false; error: E };
function tryCatch<T>(fn: () => T): Result<T, Error> {
try {
const value = fn();
return { success: true, value };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error : new Error(String(error))
};
}
}
async function tryCatchAsync<T>(
fn: () => Promise<T>
): Promise<Result<T, Error>> {
try {
const value = await fn();
return { success: true, value };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error : new Error(String(error))
};
}
}
// Usage
const result1 = tryCatch(() => {
return JSON.parse('{"name":"Alice"}');
});
if (result1.success) {
console.log(result1.value);
}
const result2 = tryCatch(() => {
return JSON.parse('invalid json');
});
if (!result2.success) {
console.error(result2.error.message);
}
// Async example
async function example() {
const result = await tryCatchAsync(async () => {
const response = await fetch("/api/data");
return response.json();
});
if (result.success) {
console.log(result.value);
} else {
console.error(result.error);
}
}
5. Error Logger
- Log errors with timestamp
- Different log levels
- Store error history
💡 Show Answer
type LogLevel = "info" | "warn" | "error";
interface LogEntry {
level: LogLevel;
message: string;
error?: Error;
timestamp: Date;
}
class ErrorLogger {
private logs: LogEntry[] = [];
log(level: LogLevel, message: string, error?: Error): void {
const entry: LogEntry = {
level,
message,
error,
timestamp: new Date()
};
this.logs.push(entry);
const prefix =`[${level.toUpperCase()}] ${entry.timestamp.toISOString()}`;
if (error) {
console.log(`${prefix} ${message}:`);
console.log(error);
} else {
console.log(`${prefix} ${message}`);
}
}
info(message: string): void {
this.log("info", message);
}
warn(message: string, error?: Error): void {
this.log("warn", message, error);
}
error(message: string, error: Error): void {
this.log("error", message, error);
}
getHistory(): LogEntry[] {
return [...this.logs];
}
getErrorCount(): number {
return this.logs.filter(log => log.level === "error").length;
}
clear(): void {
this.logs = [];
}
}
// Usage
const logger = new ErrorLogger();
logger.info("Application started");
try {
throw new Error("Something went wrong");
} catch (error) {
if (error instanceof Error) {
logger.error("Operation failed", error);
}
}
logger.warn("Low memory");
console.log("\nError count:", logger.getErrorCount());
console.log("Log history:", logger.getHistory());
6. Async Error Handler
- Handle async function errors
- Retry on failure
- Log attempts
💡 Show Answer
async function handleAsync<T>(
fn: () => Promise<T>,
maxRetries: number = 3
): Promise<T> {
let lastError: Error;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
console.log(`Attempt ${attempt}/${maxRetries}`);
return await fn();
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
console.log(`Attempt ${attempt} failed: ${lastError.message}`);
if (attempt < maxRetries) {
const delay = 1000 * attempt;
console.log(`Waiting ${delay}ms before retry...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
throw new Error(`Failed after ${maxRetries} attempts: ${lastError!.message}`);
}
// Usage
async function unreliableFunction(): Promise<string> {
if (Math.random() > 0.6) {
return "Success!";
}
throw new Error("Random failure");
}
async function example() {
try {
const result = await handleAsync(unreliableFunction, 5);
console.log("Final result:", result);
} catch (error) {
console.error("All attempts failed:", error);
}
}
example();
7. Multiple Error Types
- Handle different error types differently
- DatabaseError, NetworkError, ValidationError
- Appropriate handling for each
💡 Show Answer
class DatabaseError extends Error {
constructor(
message: string,
public query: string
) {
super(message);
this.name = "DatabaseError";
}
}
class NetworkError extends Error {
constructor(
message: string,
public url: string,
public statusCode?: number
) {
super(message);
this.name = "NetworkError";
}
}
class ValidationError extends Error {
constructor(
message: string,
public field: string
) {
super(message);
this.name = "ValidationError";
}
}
function handleError(error: unknown): void {
if (error instanceof DatabaseError) {
console.error(`Database error: ${error.message}`);
console.error(`Query: ${error.query}`);
// Maybe retry or use cached data
} else if (error instanceof NetworkError) {
console.error(`Network error: ${error.message}`);
console.error(`URL: ${error.url}`);
if (error.statusCode) {
console.error(`Status: ${error.statusCode}`);
}
// Maybe retry request
} else if (error instanceof ValidationError) {
console.error(`Validation error in ${error.field}: ${error.message}`);
// Show user feedback
} else if (error instanceof Error) {
console.error(`Unknown error: ${error.message}`);
} else {
console.error(`Unexpected error: ${error}`);
}
}
// Test
try {
throw new DatabaseError("Connection failed", "SELECT * FROM users");
} catch (error) {
handleError(error);
}
try {
throw new NetworkError("Timeout", "https://api.example.com", 408);
} catch (error) {
handleError(error);
}
try {
throw new ValidationError("Invalid email format", "email");
} catch (error) {
handleError(error);
}
8. Error Boundary Class
- Catch and handle errors in operations
- Provide fallback values
- Log all errors
💡 Show Answer
class ErrorBoundary<T> {
private errorLog: Error[] = [];
execute(
operation: () => T,
fallback: T
): T {
try {
return operation();
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
this.errorLog.push(err);
console.error(`Error caught: ${err.message}`);
return fallback;
}
}
async executeAsync(
operation: () => Promise<T>,
fallback: T
): Promise<T> {
try {
return await operation();
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
this.errorLog.push(err);
console.error(`Error caught: ${err.message}`);
return fallback;
}
}
getErrors(): Error[] {
return [...this.errorLog];
}
clearErrors(): void {
this.errorLog = [];
}
hasErrors(): boolean {
return this.errorLog.length > 0;
}
}
// Usage
const boundary = new ErrorBoundary<number>();
const result1 = boundary.execute(() => {
return JSON.parse('{"value": 42}').value;
}, 0);
console.log(result1); // 42
const result2 = boundary.execute(() => {
return JSON.parse('invalid').value;
}, 0);
console.log(result2); // 0 (fallback)
console.log("Has errors:", boundary.hasErrors());
console.log("Error count:", boundary.getErrors().length);
9. Error Chain
- Track error chain/cause
- Show full error context
- Nested error handling
💡 Show Answer
class ChainedError extends Error {
constructor(
message: string,
public cause?: Error
) {
super(message);
this.name = "ChainedError";
}
getFullMessage(): string {
let message = this.message;
let current: Error | undefined = this.cause;
while (current) {
message +=`\n Caused by: ${current.message}`;
current = current instanceof ChainedError ? current.cause : undefined;
}
return message;
}
getCauseChain(): Error[] {
const chain: Error[] = [this];
let current: Error | undefined = this.cause;
while (current) {
chain.push(current);
current = current instanceof ChainedError ? current.cause : undefined;
}
return chain;
}
}
// Example
function level3Operation(): void {
throw new Error("Database connection failed");
}
function level2Operation(): void {
try {
level3Operation();
} catch (error) {
throw new ChainedError(
"Failed to fetch user data",
error instanceof Error ? error : new Error(String(error))
);
}
}
function level1Operation(): void {
try {
level2Operation();
} catch (error) {
throw new ChainedError(
"Failed to load dashboard",
error instanceof Error ? error : new Error(String(error))
);
}
}
// Test
try {
level1Operation();
} catch (error) {
if (error instanceof ChainedError) {
console.log(error.getFullMessage());
console.log("\nChain length:", error.getCauseChain().length);
}
}
10. Safe JSON Parser
- Parse JSON safely
- Return Result type
- Include parsing error details
💡 Show Answer
type ParseResult<T> =
| { success: true; data: T }
| { success: false; error: string; position?: number };
function safeJsonParse<T>(json: string): ParseResult<T> {
try {
const data = JSON.parse(json) as T;
return { success: true, data };
} catch (error) {
let errorMessage = "Invalid JSON";
let position: number | undefined;
if (error instanceof SyntaxError) {
errorMessage = error.message;
// Try to extract position from error message
const match = error.message.match(/position (\d+)/);
if (match) {
position = parseInt(match[1], 10);
}
}
return {
success: false,
error: errorMessage,
position
};
}
}
// Usage
const validJson = '{"name":"Alice","age":30}';
const invalidJson = '{"name":"Bob",age:25}'; // Missing quotes around 'age'
const result1 = safeJsonParse(validJson);
if (result1.success) {
console.log("Parsed:", result1.data);
}
const result2 = safeJsonParse(invalidJson);
if (!result2.success) {
console.error("Parse error:", result2.error);
if (result2.position !== undefined) {
console.error("At position:", result2.position);
}
}
// With type checking
interface User {
name: string;
age: number;
}
function isUser(obj: any): obj is User {
return (
typeof obj === "object" &&
typeof obj.name === "string" &&
typeof obj.age === "number"
);
}
function parseUser(json: string): ParseResult<User> {
const result = safeJsonParse<User>(json);
if (!result.success) {
return result;
}
if (!isUser(result.data)) {
return {
success: false,
error: "Invalid user structure"
};
}
return result;
}
const userResult = parseUser('{"name":"Charlie","age":35}');
if (userResult.success) {
console.log(`User: ${userResult.data.name}, Age: ${userResult.data.age}`);
}
Summary
In this chapter, you learned:
✅ Try-catch-finally error handling
✅ Creating custom error classes
✅ Error type hierarchies
✅ Type guards for errors
✅ Async error handling
✅ Result type pattern
✅ Error logging and tracking
✅ Retry logic
Key Takeaways:
- Extend Error class for custom errors
- Use meaningful error names and messages
- Include relevant context in custom errors
- Handle errors at appropriate levels
- Use type guards to identify error types
- Consider Result type instead of throwing
- Log errors for debugging
Best Practices:
- Create specific error types for different scenarios
- Include context (field names, IDs, etc.)
- Use Error.captureStackTrace for better stack traces
- Handle errors close to where they occur
- Re-throw if you can't handle the error
- Always clean up in finally blocks
- Log errors for monitoring
Common Patterns:
- Validation errors with field context
- HTTP errors with status codes
- Not found errors with resource info
- Authentication/authorization errors
- Database/network errors
- Retry logic with exponential backoff
- Error boundaries for graceful degradation
In the next chapter, we'll explore Testing with Jest !
Continue to Chapter 17: Testing with Jest →