From Zero to AI

Lesson 3.2: Error Handling with try/catch

Duration: 60 minutes

Learning Objectives

By the end of this lesson, you will be able to:

  1. Handle errors in async functions using try/catch blocks
  2. Understand how rejected Promises behave with await
  3. Implement error handling strategies for multiple async operations
  4. Create custom error types for better error handling
  5. Use finally blocks for cleanup operations

Introduction

When working with asynchronous code, things can go wrong: network requests fail, APIs return errors, or data is malformed. In Promise chains, you handle errors with .catch(). With async/await, you use the familiar try/catch syntax from synchronous JavaScript.

This is one of the biggest advantages of async/await: error handling works exactly like it does in synchronous code.


Basic try/catch with Async/Await

When an awaited Promise rejects, it throws an exception that you can catch with a standard try/catch block.

async function fetchUser(userId: number): Promise<User> {
  try {
    const response = await fetch(`https://api.example.com/users/${userId}`);
    const user = await response.json();
    return user;
  } catch (error) {
    console.error('Failed to fetch user:', error);
    throw error; // Re-throw to let the caller handle it
  }
}

How It Works

  1. Code inside try executes normally
  2. If any await rejects, execution jumps to catch
  3. The catch block receives the rejection reason
  4. Code after try/catch continues normally (unless you re-throw)

Comparing Promise .catch() and try/catch

Both approaches handle errors, but try/catch is often cleaner.

Promise .catch() Version

function loadData(): Promise<Data> {
  return fetchConfig()
    .then((config) => fetchData(config.url))
    .then((data) => processData(data))
    .catch((error) => {
      console.error('Error:', error);
      return getDefaultData();
    });
}

try/catch Version

async function loadData(): Promise<Data> {
  try {
    const config = await fetchConfig();
    const data = await fetchData(config.url);
    return processData(data);
  } catch (error) {
    console.error('Error:', error);
    return getDefaultData();
  }
}

The try/catch version makes it clear which operations are grouped together and what happens when any of them fails.


Handling Specific Errors

Not all errors are the same. You might want to handle network errors differently from validation errors.

class NetworkError extends Error {
  constructor(
    message: string,
    public statusCode: number
  ) {
    super(message);
    this.name = 'NetworkError';
  }
}

class ValidationError extends Error {
  constructor(
    message: string,
    public field: string
  ) {
    super(message);
    this.name = 'ValidationError';
  }
}

async function createUser(userData: UserInput): Promise<User> {
  try {
    const response = await fetch('/api/users', {
      method: 'POST',
      body: JSON.stringify(userData),
    });

    if (!response.ok) {
      throw new NetworkError('Request failed', response.status);
    }

    return await response.json();
  } catch (error) {
    if (error instanceof NetworkError) {
      if (error.statusCode === 400) {
        console.error('Invalid user data');
      } else if (error.statusCode === 409) {
        console.error('User already exists');
      }
    } else if (error instanceof ValidationError) {
      console.error(`Validation failed for ${error.field}`);
    } else {
      console.error('Unknown error:', error);
    }
    throw error;
  }
}

The finally Block

The finally block runs regardless of whether the try block succeeds or fails. It is perfect for cleanup operations.

async function fetchWithLoading(url: string): Promise<Data> {
  showLoadingSpinner();

  try {
    const response = await fetch(url);
    const data = await response.json();
    return data;
  } catch (error) {
    showErrorMessage('Failed to load data');
    throw error;
  } finally {
    hideLoadingSpinner(); // Always runs
  }
}

Common Use Cases for finally

  1. Hiding loading indicators
  2. Closing database connections
  3. Releasing file handles
  4. Cleaning up temporary resources
async function processFile(filePath: string): Promise<void> {
  let fileHandle: FileHandle | null = null;

  try {
    fileHandle = await openFile(filePath);
    const contents = await fileHandle.read();
    await processContents(contents);
  } catch (error) {
    console.error('Error processing file:', error);
  } finally {
    if (fileHandle) {
      await fileHandle.close(); // Always close the file
    }
  }
}

Error Handling Strategies

Strategy 1: Fail Fast

Stop at the first error and let it propagate up:

async function processOrders(orderIds: number[]): Promise<Order[]> {
  const orders: Order[] = [];

  for (const id of orderIds) {
    // If any order fails, the entire function fails
    const order = await fetchOrder(id);
    orders.push(order);
  }

  return orders;
}

Strategy 2: Collect Errors

Continue processing and collect all errors:

interface Result<T> {
  data: T | null;
  error: Error | null;
}

async function processOrdersSafe(orderIds: number[]): Promise<Result<Order>[]> {
  const results: Result<Order>[] = [];

  for (const id of orderIds) {
    try {
      const order = await fetchOrder(id);
      results.push({ data: order, error: null });
    } catch (error) {
      results.push({ data: null, error: error as Error });
    }
  }

  return results;
}

// Usage
const results = await processOrdersSafe([1, 2, 3]);
const successful = results.filter((r) => r.data !== null);
const failed = results.filter((r) => r.error !== null);

Strategy 3: Retry on Failure

Implement retry logic for transient errors:

async function fetchWithRetry<T>(
  operation: () => Promise<T>,
  maxRetries: number = 3,
  delayMs: number = 1000
): Promise<T> {
  let lastError: Error | null = null;

  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await operation();
    } catch (error) {
      lastError = error as Error;
      console.log(`Attempt ${attempt} failed: ${lastError.message}`);

      if (attempt < maxRetries) {
        await delay(delayMs * attempt); // Exponential backoff
      }
    }
  }

  throw new Error(`All ${maxRetries} attempts failed. Last error: ${lastError?.message}`);
}

// Usage
const data = await fetchWithRetry(() => fetch('/api/data').then((r) => r.json()));

Handling Errors in Parallel Operations

When using Promise.all(), if any Promise rejects, the entire operation fails. You might want to handle this differently.

Default Behavior: One Failure Fails All

async function fetchAllUsers(ids: number[]): Promise<User[]> {
  try {
    // If any fetch fails, all results are lost
    const users = await Promise.all(ids.map((id) => fetchUser(id)));
    return users;
  } catch (error) {
    console.error('At least one fetch failed:', error);
    return [];
  }
}

Better: Use Promise.allSettled()

async function fetchAllUsersSafe(ids: number[]): Promise<User[]> {
  const results = await Promise.allSettled(ids.map((id) => fetchUser(id)));

  const users: User[] = [];

  for (const result of results) {
    if (result.status === 'fulfilled') {
      users.push(result.value);
    } else {
      console.error('Failed to fetch user:', result.reason);
    }
  }

  return users;
}

Typing Errors in TypeScript

TypeScript catches errors as unknown by default (in strict mode). You need to narrow the type.

async function handleRequest(): Promise<void> {
  try {
    await riskyOperation();
  } catch (error) {
    // error is 'unknown'

    if (error instanceof Error) {
      console.error('Error message:', error.message);
      console.error('Stack trace:', error.stack);
    } else if (typeof error === 'string') {
      console.error('Error string:', error);
    } else {
      console.error('Unknown error:', error);
    }
  }
}

Creating Type Guards for Errors

interface ApiError {
  code: string;
  message: string;
  details?: Record<string, string>;
}

function isApiError(error: unknown): error is ApiError {
  return typeof error === 'object' && error !== null && 'code' in error && 'message' in error;
}

async function callApi(): Promise<Data> {
  try {
    return await fetchFromApi();
  } catch (error) {
    if (isApiError(error)) {
      // TypeScript knows error is ApiError here
      console.error(`API Error [${error.code}]: ${error.message}`);
    }
    throw error;
  }
}

Exercise

Implement error handling for the following function that fetches user profile data from multiple sources:

interface UserProfile {
  user: User;
  posts: Post[];
  followers: User[];
}

async function getUserProfile(userId: number): Promise<UserProfile> {
  const user = await fetchUser(userId);
  const posts = await fetchUserPosts(userId);
  const followers = await fetchUserFollowers(userId);

  return { user, posts, followers };
}

Requirements:

  1. If fetching the user fails, throw an error (user is required)
  2. If fetching posts fails, use an empty array
  3. If fetching followers fails, use an empty array
  4. Log all errors that occur

Solution:

async function getUserProfile(userId: number): Promise<UserProfile> {
  // User is required - let errors propagate
  const user = await fetchUser(userId);

  // Posts are optional
  let posts: Post[] = [];
  try {
    posts = await fetchUserPosts(userId);
  } catch (error) {
    console.error('Failed to fetch posts:', error);
  }

  // Followers are optional
  let followers: User[] = [];
  try {
    followers = await fetchUserFollowers(userId);
  } catch (error) {
    console.error('Failed to fetch followers:', error);
  }

  return { user, posts, followers };
}

Key Takeaways

  1. Use try/catch to handle errors in async functions - it works just like synchronous error handling
  2. Rejected Promises throw exceptions when awaited
  3. Use finally for cleanup that must happen regardless of success or failure
  4. Create custom error classes for specific error types
  5. Choose your error strategy: fail fast, collect errors, or retry
  6. Use Promise.allSettled() when you want to continue despite individual failures
  7. In TypeScript, always narrow the error type before using error properties

Resources

Resource Type Level
MDN: try...catch Documentation Beginner
MDN: Promise.allSettled() Documentation Intermediate
Error Handling in TypeScript Documentation Intermediate

Next Lesson

Continue to Lesson 3.3: Parallel Execution to learn how to run multiple async operations concurrently for better performance.