Lesson 3.2: Error Handling with try/catch
Duration: 60 minutes
Learning Objectives
By the end of this lesson, you will be able to:
- Handle errors in async functions using try/catch blocks
- Understand how rejected Promises behave with await
- Implement error handling strategies for multiple async operations
- Create custom error types for better error handling
- 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
- Code inside
tryexecutes normally - If any
awaitrejects, execution jumps tocatch - The
catchblock receives the rejection reason - 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
- Hiding loading indicators
- Closing database connections
- Releasing file handles
- 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:
- If fetching the user fails, throw an error (user is required)
- If fetching posts fails, use an empty array
- If fetching followers fails, use an empty array
- 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
- Use try/catch to handle errors in async functions - it works just like synchronous error handling
- Rejected Promises throw exceptions when awaited
- Use finally for cleanup that must happen regardless of success or failure
- Create custom error classes for specific error types
- Choose your error strategy: fail fast, collect errors, or retry
- Use Promise.allSettled() when you want to continue despite individual failures
- 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.