Skip to main content

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 Result type.
  • 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 →