From Zero to AI

Lesson 2.3: then, catch, finally

Duration: 60 minutes

Learning Objectives

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

  • Consume Promises using .then() to handle success
  • Handle errors using .catch()
  • Execute cleanup code with .finally()
  • Chain multiple .then() calls for sequential operations
  • Understand how values flow through a Promise chain

Consuming Promises with .then()

The .then() method registers a callback to be called when the Promise is fulfilled:

const promise = Promise.resolve('Hello, World!');

promise.then((value) => {
  console.log(value); // "Hello, World!"
});

Basic Syntax

promise.then(onFulfilled, onRejected);

// onFulfilled: Called when Promise resolves
// onRejected: Optional, called when Promise rejects

Example with Async Operation

function fetchUser(id: number): Promise<{ id: number; name: string }> {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({ id, name: 'Alice' });
    }, 1000);
  });
}

fetchUser(1).then((user) => {
  console.log(`User: ${user.name}`); // "User: Alice" (after 1 second)
});

console.log('Fetching user...'); // This runs first!

Output:

Fetching user...
User: Alice

Handling Errors with .catch()

The .catch() method handles rejected Promises:

const promise = Promise.reject(new Error('Something went wrong'));

promise.catch((error) => {
  console.log('Error:', error.message); // "Error: Something went wrong"
});

Why Use .catch()?

You could handle errors in .then():

promise.then(
  (value) => console.log('Success:', value),
  (error) => console.log('Error:', error.message)
);

But .catch() is cleaner and catches errors from the entire chain:

fetchUser(1)
  .then((user) => {
    throw new Error('Processing failed!'); // This error...
  })
  .catch((error) => {
    console.log('Caught:', error.message); // ...is caught here!
  });

Practical Example

function fetchData(url: string): Promise<string> {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (url === '') {
        reject(new Error('URL cannot be empty'));
        return;
      }
      if (url.includes('error')) {
        reject(new Error('Server error'));
        return;
      }
      resolve(`Data from ${url}`);
    }, 500);
  });
}

// Success case
fetchData('https://api.example.com/users')
  .then((data) => console.log('Data:', data))
  .catch((error) => console.log('Error:', error.message));
// Output: "Data: Data from https://api.example.com/users"

// Error case
fetchData('https://api.example.com/error')
  .then((data) => console.log('Data:', data))
  .catch((error) => console.log('Error:', error.message));
// Output: "Error: Server error"

Cleanup with .finally()

The .finally() method runs regardless of whether the Promise was fulfilled or rejected:

function loadData(): Promise<string> {
  return new Promise((resolve) => {
    setTimeout(() => resolve('Data loaded'), 1000);
  });
}

console.log('Loading...');

loadData()
  .then((data) => console.log('Success:', data))
  .catch((error) => console.log('Error:', error.message))
  .finally(() => console.log('Cleanup complete'));

Common Use Cases for .finally()

  1. Hide loading indicators:
let isLoading = true;

fetchData()
  .then((data) => displayData(data))
  .catch((error) => showError(error))
  .finally(() => {
    isLoading = false; // Always runs
    hideSpinner();
  });
  1. Close connections:
openDatabase()
  .then((db) => db.query('SELECT * FROM users'))
  .then((users) => processUsers(users))
  .catch((error) => logError(error))
  .finally(() => closeDatabase()); // Always close
  1. Reset state:
let buttonDisabled = true;

submitForm()
  .then((result) => showSuccess(result))
  .catch((error) => showError(error))
  .finally(() => {
    buttonDisabled = false; // Re-enable button
  });

.finally() Does Not Receive Arguments

Promise.resolve('Hello')
  .finally(() => {
    // No access to the resolved value here
    console.log('Cleaning up');
  })
  .then((value) => {
    // But the value passes through!
    console.log(value); // "Hello"
  });

Promise Chaining

The real power of Promises is chaining. Each .then() returns a new Promise, allowing you to chain operations:

Promise.resolve(1)
  .then((value) => {
    console.log(value); // 1
    return value + 1;
  })
  .then((value) => {
    console.log(value); // 2
    return value * 2;
  })
  .then((value) => {
    console.log(value); // 4
  });

How Values Flow Through the Chain

Promise.resolve(1)
    │
    ▼ value = 1
.then(v => v + 1)  →  Returns Promise<2>
    │
    ▼ value = 2
.then(v => v * 2)  →  Returns Promise<4>
    │
    ▼ value = 4
.then(v => console.log(v))

Returning Promises in .then()

When you return a Promise from .then(), the chain waits for it to resolve:

function step1(): Promise<number> {
  return new Promise((resolve) => {
    setTimeout(() => resolve(10), 500);
  });
}

function step2(value: number): Promise<number> {
  return new Promise((resolve) => {
    setTimeout(() => resolve(value * 2), 500);
  });
}

function step3(value: number): Promise<string> {
  return new Promise((resolve) => {
    setTimeout(() => resolve(`Result: ${value}`), 500);
  });
}

step1()
  .then((result) => step2(result)) // Waits for step2
  .then((result) => step3(result)) // Waits for step3
  .then((result) => console.log(result)); // "Result: 20" (after 1.5 seconds)

Practical Chaining Example

interface User {
  id: number;
  name: string;
}

interface Post {
  id: number;
  userId: number;
  title: string;
}

interface Comment {
  id: number;
  postId: number;
  body: string;
}

function fetchUser(id: number): Promise<User> {
  return new Promise((resolve) => {
    setTimeout(() => resolve({ id, name: 'Alice' }), 300);
  });
}

function fetchPosts(userId: number): Promise<Post[]> {
  return new Promise((resolve) => {
    setTimeout(
      () =>
        resolve([
          { id: 1, userId, title: 'First Post' },
          { id: 2, userId, title: 'Second Post' },
        ]),
      300
    );
  });
}

function fetchComments(postId: number): Promise<Comment[]> {
  return new Promise((resolve) => {
    setTimeout(
      () =>
        resolve([
          { id: 1, postId, body: 'Great post!' },
          { id: 2, postId, body: 'Thanks for sharing' },
        ]),
      300
    );
  });
}

// Chained operations - clean and readable!
fetchUser(1)
  .then((user) => {
    console.log(`User: ${user.name}`);
    return fetchPosts(user.id);
  })
  .then((posts) => {
    console.log(`Posts: ${posts.length}`);
    return fetchComments(posts[0].id);
  })
  .then((comments) => {
    console.log(`Comments: ${comments.length}`);
  })
  .catch((error) => {
    console.error('Something went wrong:', error.message);
  });

Compare this to the callback hell version - much cleaner!


Error Propagation in Chains

Errors propagate down the chain until they are caught:

Promise.resolve(1)
  .then((value) => {
    throw new Error('Error in step 1');
    return value + 1; // Never reached
  })
  .then((value) => {
    console.log('Step 2:', value); // Never reached
    return value * 2;
  })
  .then((value) => {
    console.log('Step 3:', value); // Never reached
  })
  .catch((error) => {
    console.log('Caught:', error.message); // "Caught: Error in step 1"
  });

Error Flow Diagram

.then(step1)  ──── Error! ────┐
      │                       │
      │                       │
.then(step2)  ← skipped ←─────┤
      │                       │
      │                       │
.then(step3)  ← skipped ←─────┤
      │                       │
      │                       │
.catch(handler) ←─────────────┘

Recovering from Errors

You can recover from errors by catching them mid-chain:

Promise.resolve(1)
  .then((value) => {
    throw new Error('Oops!');
  })
  .catch((error) => {
    console.log('Recovering from:', error.message);
    return 0; // Recovery value
  })
  .then((value) => {
    console.log('Recovered with:', value); // "Recovered with: 0"
  });

Multiple .catch() Handlers

fetchUser(1)
  .then((user) => {
    if (!user.name) {
      throw new Error('Invalid user');
    }
    return fetchPosts(user.id);
  })
  .catch((error) => {
    // Handle user-related errors
    console.log('User error:', error.message);
    return []; // Return empty posts array
  })
  .then((posts) => {
    console.log('Posts count:', posts.length);
    return posts;
  })
  .catch((error) => {
    // Handle any remaining errors
    console.log('Unexpected error:', error.message);
  });

Common Patterns

Pattern 1: Transform Data

interface ApiUser {
  user_id: number;
  full_name: string;
  email_address: string;
}

interface User {
  id: number;
  name: string;
  email: string;
}

function fetchApiUser(): Promise<ApiUser> {
  return Promise.resolve({
    user_id: 1,
    full_name: 'Alice Smith',
    email_address: 'alice@example.com',
  });
}

fetchApiUser()
  .then(
    (apiUser): User => ({
      id: apiUser.user_id,
      name: apiUser.full_name,
      email: apiUser.email_address,
    })
  )
  .then((user) => {
    console.log(user);
    // { id: 1, name: "Alice Smith", email: "alice@example.com" }
  });

Pattern 2: Conditional Branching

function fetchData(useCache: boolean): Promise<string> {
  if (useCache) {
    return Promise.resolve('Cached data');
  }
  return fetchFromServer();
}

fetchData(true)
  .then((data) => console.log('Data:', data))
  .catch((error) => console.log('Error:', error.message));

Pattern 3: Retry Logic

function fetchWithRetry(url: string, retries: number = 3): Promise<string> {
  return fetch(url).catch((error) => {
    if (retries > 0) {
      console.log(`Retrying... (${retries} attempts left)`);
      return fetchWithRetry(url, retries - 1);
    }
    throw error;
  });
}

Pattern 4: Timeout Handling

function timeout<T>(promise: Promise<T>, ms: number): Promise<T> {
  const timeoutPromise = new Promise<never>((_, reject) => {
    setTimeout(() => reject(new Error('Timeout')), ms);
  });

  return Promise.race([promise, timeoutPromise]);
}

// Usage
timeout(fetchUser(1), 5000)
  .then((user) => console.log('User:', user))
  .catch((error) => console.log('Error:', error.message));

Common Mistakes

Mistake 1: Forgetting to Return

// Wrong - the chain breaks
fetchUser(1)
  .then((user) => {
    fetchPosts(user.id); // Forgot return!
  })
  .then((posts) => {
    console.log(posts); // undefined!
  });

// Correct
fetchUser(1)
  .then((user) => {
    return fetchPosts(user.id); // Return the Promise
  })
  .then((posts) => {
    console.log(posts); // Posts array
  });

Mistake 2: Nesting Instead of Chaining

// Wrong - callback hell with Promises!
fetchUser(1).then((user) => {
  fetchPosts(user.id).then((posts) => {
    fetchComments(posts[0].id).then((comments) => {
      console.log(comments);
    });
  });
});

// Correct - flat chain
fetchUser(1)
  .then((user) => fetchPosts(user.id))
  .then((posts) => fetchComments(posts[0].id))
  .then((comments) => console.log(comments));

Mistake 3: Missing Error Handler

// Dangerous - unhandled rejection
fetchUser(1).then((user) => {
  throw new Error('Oops!');
});

// Always add .catch()
fetchUser(1)
  .then((user) => {
    throw new Error('Oops!');
  })
  .catch((error) => {
    console.error('Handled:', error.message);
  });

Mistake 4: Catching Too Early

// Problem - error is swallowed
fetchUser(1)
  .catch((error) => {
    console.log('Error:', error.message);
    // Returns undefined, chain continues
  })
  .then((user) => {
    console.log(user); // undefined if error occurred
  });

// Better - rethrow if needed
fetchUser(1)
  .catch((error) => {
    console.log('Logging error:', error.message);
    throw error; // Rethrow for downstream handling
  })
  .then((user) => {
    console.log(user);
  })
  .catch((error) => {
    console.log('Final handler:', error.message);
  });

Exercises

Exercise 1: Basic Chain

Create a Promise chain that:

  1. Starts with the number 5
  2. Doubles it
  3. Adds 10
  4. Logs the final result
Solution
Promise.resolve(5)
  .then((value) => value * 2) // 10
  .then((value) => value + 10) // 20
  .then((value) => console.log('Result:', value)); // "Result: 20"

Exercise 2: Error Handling

Fix this code so errors are properly handled:

function riskyOperation(value: number): Promise<number> {
  return new Promise((resolve, reject) => {
    if (value < 0) {
      reject(new Error('Value must be positive'));
    } else {
      resolve(value * 2);
    }
  });
}

// Fix this chain
riskyOperation(-5).then((result) => console.log('Result:', result));
Solution
riskyOperation(-5)
  .then((result) => console.log('Result:', result))
  .catch((error) => console.log('Error:', error.message));
// Output: "Error: Value must be positive"

Exercise 3: Chain with .finally()

Create a chain that:

  1. Simulates a data fetch (resolve after 500ms with "Data loaded")
  2. Logs the data
  3. Has error handling
  4. Always logs "Operation complete" at the end
Solution
function fetchData(): Promise<string> {
  return new Promise((resolve) => {
    setTimeout(() => resolve('Data loaded'), 500);
  });
}

fetchData()
  .then((data) => console.log('Success:', data))
  .catch((error) => console.log('Error:', error.message))
  .finally(() => console.log('Operation complete'));

// Output:
// Success: Data loaded
// Operation complete

Exercise 4: Sequential Operations

Create functions and chain them to:

  1. Get a user by ID (return { id, name })
  2. Get the user's settings (return { theme: "dark", language: "en" })
  3. Log both the user and settings

Each step should take 300ms.

Solution
interface User {
  id: number;
  name: string;
}

interface Settings {
  theme: string;
  language: string;
}

function getUser(id: number): Promise<User> {
  return new Promise((resolve) => {
    setTimeout(() => resolve({ id, name: 'Alice' }), 300);
  });
}

function getSettings(userId: number): Promise<Settings> {
  return new Promise((resolve) => {
    setTimeout(() => resolve({ theme: 'dark', language: 'en' }), 300);
  });
}

let currentUser: User;

getUser(1)
  .then((user) => {
    currentUser = user;
    console.log('User:', user.name);
    return getSettings(user.id);
  })
  .then((settings) => {
    console.log('Settings:', settings);
    console.log(`${currentUser.name} uses ${settings.theme} theme`);
  })
  .catch((error) => console.log('Error:', error.message));

Exercise 5: Error Recovery

Create a chain that:

  1. Attempts to fetch data (rejects with "Network error")
  2. Catches the error and returns a default value "Default data"
  3. Processes the data (either fetched or default)
  4. Logs the final result
Solution
function fetchData(): Promise<string> {
  return Promise.reject(new Error('Network error'));
}

function processData(data: string): string {
  return data.toUpperCase();
}

fetchData()
  .catch((error) => {
    console.log('Fetch failed:', error.message);
    return 'Default data'; // Recovery value
  })
  .then((data) => processData(data))
  .then((processed) => console.log('Result:', processed));

// Output:
// Fetch failed: Network error
// Result: DEFAULT DATA

Key Takeaways

  1. .then() handles successful Promise resolution
  2. .catch() handles errors and can recover with a return value
  3. .finally() runs regardless of success/failure for cleanup
  4. Chaining allows sequential async operations without nesting
  5. Return values from .then() become the input for the next .then()
  6. Returning a Promise in .then() waits for it before continuing
  7. Errors propagate down the chain until caught
  8. Always include error handling in your Promise chains

Resources

Resource Type Description
MDN: Promise.prototype.then() Documentation Complete then() reference
MDN: Promise.prototype.catch() Documentation Error handling with catch()
JavaScript.info: Promise Chaining Tutorial Interactive chaining tutorial

Next Lesson

Now that you can chain Promises, let us learn how to work with multiple Promises at once using Promise.all() and Promise.race().

Continue to Lesson 2.4: Promise.all and Promise.race