1
0
0
0
0
javascript
typescript
promises
language
syntax
theory
full-guide

All About JavaScript Promises

Problems are the root of invention and progress. One of the most annoying issues that JavaScript developers faced several years ago was Callback Hell. The insane syntax and complexity of code frustrated many developers and made JavaScript appear unattractive to others.

Now, the language is much more developer-friendly thanks to some abstractions that have been created. One of them is the Promise. Today, we'll learn everything about this API and master it.

Problems With Callbacks

Take a look at Callback Hell - the main reason for Promise involvement:

// Simulating asynchronous operations with callbacks.
function asyncOperation1(callback) {
  setTimeout(() => {
    console.log("Operation 1 complete");
    callback(null, "Result of operation 1");
  }, 1000);
}

function asyncOperation2(result1, callback) {
  setTimeout(() => {
    console.log("Operation 2 complete with input:", result1);
    callback(null, "Result of operation 2");
  }, 1000);
}

// Callback hell example
asyncOperation1((err, result1) => {
  if (err) {
    console.error("Error in operation 1:", err);
    return;
  }
  asyncOperation2(result1, (err, result2) => {
    if (err) {
      console.error("Error in operation 2:", err);
      return;
    }
    console.log("Final result:", result2);
  });
});

The JavaScript community has created memes highlighting how insanely difficult it is to understand this syntax.

Callback Hell Meme Meme

In addition, callbacks themselves are stateless and lack a unified structure. Different libraries may implement callbacks in various ways to pass an error, response, or other data, leading to inconsistencies.

// Inconsistency...
lib1((err, data) => {
  if (err) {
    return;
  }

  lib2(({ err, data }) => {
    if (err) {
      return;
    }

    // Do other stuff...
  });
});

Next, you may notice the duplication of error handling logic. Every time, you need to add an if statement at every nested level of the code.

lib1((err, data) => {
  // The repeated part.
  if (err) { 
    return;
  }

It's not the end yet :D. With the callback approach, we can clearly see that the behavior of each callback often depends on the previous one. As a result, the code we need to write to handle such behavior becomes very complex. Imagine needing to change the order of callbacks - good luck with that...

asyncOperation1((err, result1) => {
  if (err) {
    console.error("Error in operation 1:", err);
    return;
  }
  asyncOperation2(result1, (err, result2) => {
    if (err) {
      console.error("Error in operation 2:", err);
      return;
    }
    console.log("Final result:", result2);
  });
});

Lastly, there is a lack of easy control over code behavior. Imagine React without a built-in useEffect hook, where you would need to write convoluted code to listen for state changes or component updates - it would be a nightmare. To address this, React implemented a pattern called Inversion of Control. React provides an API to execute certain functions, and as developers, we only need to provide a function without worrying about when or how it will be called. React handles the invocation for us; we just specify the logic.

const ExampleComponent = () => {
  const [count, setCount] = useState(0);

  // useEffect runs after every render.
  useEffect(() => {
    // This is the effect logic.
    console.log(`Component rendered with count: ${count}`);

    // Optionally return a cleanup function.
    return () => {
      console.log(`Cleaning up after count: ${count}`);
    };
  }, [count]); // Dependency array, effect runs when `count` changes.
}

With callbacks, we need to specify the way and moment imperatively. That's why the Promise was added to the language - to solve these problems and make the syntax much easier to work with. Here is the same version of async code management, but now implemented with promises instead of callbacks.

asyncOperation1()
  .then((result1) => {
    return asyncOperation2(result1);
  })
  .then((result2) => {
    return asyncOperation3(result2);
  })
  .then((result3) => {
    console.log("Final result:", result3);
  })
  .catch((err) => {
    console.error("Error:", err);
  });

Promises In Theory

Here is a documentation definition:

A Promise in JavaScript is an object representing the eventual completion (or failure) of an asynchronous operation and its resulting value. It provides a cleaner and more manageable way to deal with asynchronous code compared to traditional callback-based approaches.

The Promise may have three states:

  1. Pending: the initial state, neither fulfilled nor rejected.
  2. Fulfilled: the operation completed successfully.
  3. Rejected: the operation failed.

The best way to understand promises is to compare them with a real-life situation. Imagine a letter that you're sending to a family member. The letter is hidden inside an envelope. Without the envelope, the letter carrier can see what you've sent, which is risky. Additionally, to provide information about where the letter should be delivered, you would need to destroy or change the form of the letter by including this information at the top or bottom. A much better way is to hide the letter inside an envelope.

Comparing this to promises, the delivery is an action that can have three possible states, just like a Promise(pending, fulfilled, or rejected). The envelope is a Promise, and the information attached to the envelope, like the destination address, is Promise metadata.

Promises are not only implemented in JavaScript. Although a Promise is not officially a design pattern, it is widely used to handle asynchronous operations. Promises are also implemented in languages such as C#, Python, Java, Rust, and Swift.

In summary, it's a way to describe an asynchronous operation that takes time to complete, attach metadata to it, and react to changes in the operation's state. Here is the diagram from MDN Web Docs:

Promise Docs Diagram Promise Diagram From Documentation

Basic Promise Syntax

Let's examine the theory. Imagine we're writing an app that allows users to send virtual letters. We have the following process:

  1. Writing a letter.
  2. Delivering the letter.
  3. Receiving a payment.

All of these are asynchronous operations may take time. It makes them perfect candidates for using a Promise. Additionally, we want to handle all errors that may occur during this process.

interface Letter {
  id: string;
  content: string;
}

const writeLetter = (content: string): Promise<Letter> => {
  return fetch(`/api/letter/create`, { method: 'post', body: content }).then(
    (res) => res.json()
  );
};

const deliverLetter = (letter: Letter): Promise<Letter['id']> => {
  return fetch(`/api/letter/deliver`, {
    method: 'post',
    body: JSON.stringify(letter),
  }).then((res) => res.json());
};

const receivePayment = (): Promise<number> => {
  return fetch(`/api/payment/receive`).then((res) => res.json());
};

const deliveryProcess = () => {
  writeLetter('My letter content')
    .then((letter) => deliverLetter(letter))
    .then(() => receivePayment())
    .catch((e: unknown) => {
      console.log(e);
    })
    .finally(() => {
      // It's "fulfilled" or "rejected" here.
      // Or we may call it "settled". 
    });
};

// It runs the whole process.
deliveryProcess();

As you saw, we've chained several asynchronous operations into one big process. Each .then continues the next step in the process - an API call that moves the process forward. After each step is fulfilled, the next Promise is created, and the next steps are involved. If an error occurs (rejected), we handle it once inside the .catch block.

On the other hand, whether the Promise is fulfilled or rejected, we can handle logic inside .finally() - such as clean-ups, logging, state resets, and many other aspects.

It's really important to understand that a Promise can be chained as many times as needed. It is simply a wrapper for an asynchronous operation. Thanks to the then, catch, and finally methods, it allows us to handle any process at a high level without unnecessary nesting.

The Async and Await Keywords

This makes promises even simpler. Instead of directly calling then, catch, or finally, we can use async and await to wrap code with built-in JavaScript exception handling.

interface Letter {
  id: string;
  content: string;
}

const writeLetter = async (content: string): Promise<Letter> => {
  return (
    await fetch(`/api/letter/create`, { method: 'post', body: content })
  ).json();
};

const deliverLetter = async (letter: Letter): Promise<Letter['id']> => {
  return (
    await fetch(`/api/letter/deliver`, {
      method: 'post',
      body: JSON.stringify(letter),
    })
  ).json();
};

const receivePayment = async (): Promise<number> => {
  return (await fetch(`/api/payment/receive`)).json();
};

const deliveryProcess = async () => {
  try {
    const letter = await writeLetter('My letter content');
    await deliverLetter(letter);
    await receivePayment();
  } catch (e: unknown) {
    console.log(e);
  } finally {
    // It's "fulfilled" or "rejected" here.
  }
};

// It runs the whole process.
deliveryProcess();

The async keyword allows us to use await. Without the async keyword, we'll get a syntax error when trying to use await. Additionally, after declaring a function as async, we get the following behavior:

  1. If the function returns a value, the Promise is resolved with that value.
  2. If the function throws an error, the Promise is rejected with that error.

Essentially, it automatically calls the then and catch methods for us, allowing us to write more linear code with less boilerplate - especially when transforming blocks like .then(() => somePromiseFn()) to await somePromiseFn().

Now about await:

  1. When the await keyword is used, the async function is paused until the Promise is settled (fulfilled or rejected).
  2. If the Promise is fulfilled, the await expression returns the resolved value.
  3. If the Promise is rejected, the await expression throws the rejected value (similar to the throw statement).

In summary, it's just syntactic sugar, similar to how class is to the object prototype.

Converting Async Operations To Promises

Sometimes you're using legacy APIs, or you don't like the way they handle asynchronous operations. The Promise offers a way to handle such scenarios easily by converting async operations into Promise objects. Let's say you have a legacy library in the NodeJS for reading files:

import { read } from "file-reader";

read(`/path-to-file`, (err, file) => {
  if (err) {
    console.log(err);
    return;
  }

  console.log(file);
});

You can easily convert it to a promise-based version with the following code:

const readPromise = (onSuccess) => {
  return new Promise((resolve, reject) => {
    read(`/path-to-file`, (err, file) => {
      if (err) {
        // The "return" ensures that "resolve" code is not called when an error occurs.
        return reject(err); 
      }
      // In this case "return" is optional, as there is nothing more after this line.
      onSuccess();
      resolve(file);
    });
  });
};

We've used a Promise constructor that takes resolve and reject functions, allowing us to craft our custom Promise mechanism. Notice the important return statement in the error handling block. Without it, onSuccess could be called even when an error occurs, which is invalid behavior.

In the example code, we did not add return to resolve. In this case, it's fine, but if you have more complicated logic with multiple if statements, it's necessary to avoid bugs. To ensure consistency and avoid forgetting, I always add return to both resolve and reject.

if (something) return reject(err);
if (somethingElse) return resolve();
return resolve();

There is an ESLint plugin called Consistent Return to help ensure that return statements are consistent.

Adding a return before resolve is generally a good practice, especially in functions with more complex logic. It ensures that the function exits immediately after calling resolve or reject, preventing any accidental execution of subsequent code. Choose this approach and be consistent.

Promises Chaining

To understand how it works, let's consider that we have several functions that return promises. Some of them will reject.

const randomPromiseFactory = (reject: boolean) => () =>
  reject ? Promise.reject(new Error('Error')) : Promise.resolve();

const p1 = randomPromiseFactory(false);
const p2 = randomPromiseFactory(true);
const p3 = randomPromiseFactory(false);
const p4 = randomPromiseFactory(false);
const p5 = randomPromiseFactory(true);

In this code, the p2 and p5 functions will reject a value - the reject function will be called. All others will fulfill, meaning the resolve function will be called. Now, we want to achieve the following scenario:

  1. Call p1.
  2. Call p2.
  3. If any of the above fail, call p3.
  4. Call p4 regardless of whether p3 fails or succeeds.

We can use the following syntax:

// Version 1
const chain = () => {
  p1()
    .then(() => p2())
    .catch(() => p3())
    .then(() => p4());
};
// Version 2
const chain = async () => {
  try {
    await p1();
    await p2();
  } catch {
    await p3();
  }
  await p4();
};

As you saw, we've transformed each function invocation that returns a Promise into other promises to achieve the desired algorithm. What's really cool is that the nesting doesn't matter - you can have a deeply nested promise chain and catch errors at the top, or map the outcomes and handle them in the next .then blocks. Examine the following scenario:

const step1 = () => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve('Step 1 complete');
    }, 1000);
  });
};

const step2 = () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      reject('Error in step 2');
    }, 1000);
  });
};

const step3 = () => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve('Step 3 complete');
    }, 1000);
  });
};

step1()
  .then(result => {
    console.log(result);
    return step2().then(step2Result => {
      console.log(step2Result);
      return step3();
    });
  })
  .then(result => {
    console.log(result);
  })
  .catch(error => {
    console.error('Caught error:', error);
  });

// Output:
// Step 1 complete
// Caught error: Error in step 2

In this example:

  • Step 1 completes successfully and logs "Step 1 complete".
  • Step 2 fails, and the error is caught by the .catch() block, logging "Caught error: Error in step 2".
  • Step 3 is not called because the chain was interrupted by the rejection in Step 2.

Static Methods To Handle Promises

Promise.resolve(value)

Returns a Promise that is resolved with the given value.

Promise.resolve('Success').then(value => {
    console.log(value); // "Success"
});

Promise.reject(reason)

Returns a Promise that is rejected with the given reason.

Promise.reject('Error').catch(reason => {
    console.log(reason); // "Error"
});

Promise.all(iterable)

Returns a Promise that resolves when all of the promises in the iterable have resolved. It rejects when any Promise in the iterable rejects.

let promise1 = Promise.resolve(1);
let promise2 = Promise.resolve(2);

Promise.all([promise1, promise2]).then(values => {
    console.log(values); // [1, 2]
});

Promise.allSettled(iterable)

Returns a Promise that resolves when all of the promises in the iterable have settled (either fulfilled or rejected). It never rejects. Instead, it passes an array to the fulfilled callback.

let promise1 = Promise.resolve(1);
let promise2 = Promise.reject('Error');

Promise.allSettled([promise1, promise2]).then(results => {
    results.forEach(result => console.log(result.status));
    // "fulfilled"
    // "rejected"
});

Promise.any(iterable)

Returns a Promise that resolves as soon as one of the promises in the iterable resolves, with the value from that promise. If no promises resolve or all rejects, it rejects with an AggregateError.

let promise1 = Promise.reject("Error");
let promise2 = new Promise((resolve) => setTimeout(resolve, 100, "two"));
let promise3 = new Promise((resolve) => setTimeout(resolve, 200, "one"));

Promise.any([promise1, promise2, promise3])
  .then((value) => {
    console.log(value); // It will prompt "two".
  })
  .catch((error) => {
    if (error instanceof AggregateError) {
      console.error(
        "All promises were rejected. AggregateError:",
        error.errors
      );
    } else {
      console.error("An unexpected error occurred:", error);
    }
  });

Use case: You're developing an email sending service built on top of three different email providers. You want to send an email to a user following a system action. Using Promise.any, the Promise will resolve as soon as the fastest service sends the email. If all providers fail, you can handle the error using AggregateError.

Promise.race(iterable)

Promise.race resolves or rejects as soon as one of the promises in the iterable it receives settles, regardless of whether the outcome is a resolution or rejection. Essentially, it returns the result of the fastest promise.

let promise1 = new Promise((resolve) => setTimeout(resolve, 500, 'one'));
let promise2 = new Promise((resolve) => setTimeout(resolve, 100, 'two'));
let promise3 = Promise.reject("Error");

Promise.race([promise1, promise2]).then(value => {
    console.log(value); // Prompts "two".
});

Promise.race([promise1, promise2, promise3])
  .then((value) => {
    // It will be not called due to one rejection in "promise3".
    console.log(value);
  })
  .catch((error) => {
    // It will go to error "catch" block. 
    console.error("An unexpected error occurred:", error);
  });

Use Case: You have several instances of a backend API managed by a custom load balancer, located in various parts of the world. You aim to route a request to the instance closest to the user to retrieve their data. Therefore, you want to return the result to the user immediately after receiving a response or rejection from the first instance that processes the request.

Promise.withResolvers()

This method is useful when you need to create and control a Promise instance that will be resolved or rejected later, often within a callback or asynchronous mechanism.

// Example usage: Waiting for a button click.
const { promise, resolve, reject } = Promise.withResolvers();

const button = document.createElement('button');
button.textContent = 'Click me';
document.body.appendChild(button);

button.addEventListener('click', () => {
  resolve('Button was clicked');
  button.remove(); // Clean up the button after it is clicked.
});

// Handling the promise.
promise
  .then(message => {
    console.log(message);
  })
  .catch(error => {
    console.error(error);
  });

// The promise will be resolved when the button is clicked.

The key point here is that the Promise is not resolved immediately. It resolves only when the resolve function is called via a click event, which then triggers the promise.then method. This is possible because the resolve and reject functions are assigned to variables in a higher scope, allowing us to call them from anywhere without unnecessary nesting.

FAQs

If I call resolve or reject multiple times, will the .then or .catch handlers be called the same number of times?

No, calling resolve or reject multiple times on a single promise will not cause the .then or .catch handlers to be called multiple times. Once a promise is settled (either fulfilled or rejected), its state is final and cannot be changed. Subsequent calls to resolve or reject will have no effect.

Here’s an example to illustrate this behavior:

const promise = new Promise((resolve, reject) => {
    resolve('First value');
    resolve('Second value');
    reject('Error');
});

promise
  .then(value => {
    console.log('Resolved with:', value);
  })
  .catch(error => {
    console.error('Rejected with:', error);
  });

// Logs: "Resolved with: First value"

Should I always use return when using Promise.reject or Promise.resolve?

Yes, it is generally a good practice to use return when calling Promise.resolve or Promise.reject inside a function, especially within complex logic or asynchronous callbacks. This ensures that the promise chain is properly maintained and can help avoid unexpected behaviors in future code changes.

If you have any logic under reject or resolve, it will still be executed. Remember, both resolve and reject may be called multiple times, but only the first call will affect the state of the promise. Subsequent calls will have no effect on the promise's state, but they may affect the logic within your function. Consider the following example:

const readPromise = () => {
  return new Promise((resolve, reject) => {
    read(`/path-to-file`, (err, file) => {
      if (err) {
        return reject(err); 
      }
      // Ensure this function is not called if an error occurs.
      myOtherComplexFunction(); 
      resolve(file);
    });
  });
};

In this example, using return with reject ensures that myOtherComplexFunction() is not called if an error occurs. Without the return statement, myOtherComplexFunction() would still be executed even if there was an error, which is undesirable behavior.

So, always adding a return statement for reject and resolve can help prevent such issues and keep your logic clear and predictable. This practice is already explained in the article to ensure you maintain proper promise behavior.

How do Promises work under the hood?

In JavaScript, the execution of promises is managed by the event loop and the microtask queue. When a Promise is resolved or rejected, the corresponding .then, .catch, or .finally handlers are placed in the microtask queue.

Here's the relevant API:

queueMicrotask(callback);

It's important to understand that the callbacks passed to .then, .catch, and .finally are added to the microtask queue, not the internal logic of the Promise itself. The promise's internal logic is handled immediately in a synchronous manner.

What is the difference between Promise.race and Promise.any?

The name Promise.any might seem misleading. While both functions aim to find the fastest Promise, they handle resolve and reject differently. Here are the differences:

Promise.race takes multiple promises and returns the result of the first promise that settles, regardless of whether it resolves or rejects. It's like a race where the first promise to finish, for better or worse, ends the race.

Promise.any takes multiple promises and returns the result of the first promise that resolves successfully. It ignores any promises that reject unless all of them fail. If all promises reject, it throws an AggregateError. Think of it as a contest where the first success wins, but all failures are ignored unless everyone fails.

Summary

Wow, that was a huge article, but I hope everything is now clear about the Promise concept. I found it useful to write this article because it refreshed my knowledge and clarified some gaps in my understanding. The most important concepts you should remember after reading this article are:

  1. A Promise is an object representing the eventual completion or failure of an async operation, with three possible states: pending, fulfilled, and rejected.
  2. The fulfilled or rejected states are collectively known as settled.
  3. A Promise is in the pending state immediately after the Promise instance is created.
  4. The fulfilled state occurs when the resolve function is called.
  5. The rejected state occurs when the reject function is called.
  6. Promises can be chained with .then() and .catch() methods, allowing for complex asynchronous workflows.
  7. The async and await provide syntactic sugar to work with Promises more easily.
  8. The Promise API has many static methods, such as Promise.all, Promise.race, Promise.allSettled, and Promise.any, to simplify logic.
  9. You can wrap current non-promise-based APIs with the Promise constructor to make them return promises.

Remember to practice using this API to fully understand it. It can be particularly challenging on the backend with Node.js, and any gaps or misunderstandings can lead to many bugs later on.

Author avatar
About Authorpraca_praca

👋 Hi there! My name is Adrian, and I've been programming for almost 7 years 💻. I love TDD, monorepo, AI, design patterns, architectural patterns, and all aspects related to creating modern and scalable solutions 🧠.