DEV Community

Cover image for Asynchronous Javascript - Learn Promises From Scratch
Vincas Stonys
Vincas Stonys

Posted on • Originally published at codefrontend.com on

Asynchronous Javascript - Learn Promises From Scratch

JavaScript is a synchronous language at its core, meaning that only a single line of code runs at any time. That means that time spent executing some part of the code delays the execution of other code. This includes painting things on the screen, handling user input, or doing math.

When building frontend applications we often need to deal with asynchronous operations, such as user input or HTTP requests. In the browser they may run on a separate thread, so how do we deal with this asynchronicity in a synchronous language like JavaScript?

Understanding this part of JavaScript is essential to building real-world applications. And that's what we're going to take a look at in this article.

We'll learn about:

  1. Callbacks - how callbacks can be used to deal with asynchronous code, and how they lead to callback hell.

  2. Promises - how to use promises to make callbacks manageable. We'll also write our own implementation of the basic promise API to understand how they work.

  3. Async/await - how to use the new await/async API to make promises even easier to work with.

📚 Synchronicity reduces the cognitive load when writing JavaScript code since you don't need to concern yourself with multiple threads accessing and updating the same data. However, sometimes it's necessary to break out of the single thread for computationally expensive operations. To understand how to execute your own code asynchronously learn about Web Workers in the browser and Worker Threads in Node.js.

Callback functions

It would be impractical if making an HTTP request blocked the code until we got a response. Even if it came back in 100 milliseconds, that's still ages in computer time. Luckily, JavaScript allows us to provide callback functions to asynchronous APIs.

📌 A callback is a function that's passed as an argument to another function to be called by that function at a later time. Often, to indicate that some action has been finished.

In fact, you probably use callbacks without even thinking about it.

For instance, user events are asynchronous in nature, and you register callback functions that handle those events, like this:

const btn = document.querySelector("button");

btn.addEventListener("click", () => {
  // Called when the user clicks the button
});
Enter fullscreen mode Exit fullscreen mode

The example code registers a callback to be executed when the user clicks the button. In the meantime, the program continues running other code and doesn't keep waiting for user input.

📚 It's useful to know that the event handler code might not start executing immediately after the user clicks the button. Learn about the event loop to understand why.

Problems with callbacks

Callbacks are the most basic way to deal with asynchronous code. They can actually get you quite far, but at some point, they will start to feel limiting:

  • Callbacks create code-flow indirection because the function result is not returned but instead passed into a callback. This makes code harder to follow.
  • Handling errors in callbacks is a matter of convention rather than API, which creates inconsistencies and cognitive load.
  • Nested callbacks may lead to 'callback hell'. That's when callbacks are nested within other callbacks, several levels deep, often making the code hard to read and understand.

Consider this example of asynchronous JavaScript code using callbacks:

function makeTea(done) {
  const teapot = getTeapot();
  const kettle = getKettle();

  kettle.fill();

  kettle.boil((error, water) => {
    if (error) {
      done(new Error('Kettle broke 😭'));
    } else { 
      kettle.pour(water, teapot);
      waitMinutes(5, () => {
        const cup = getCup();
        const tea = teapot.pourInto(cup);
        waitMinutes(5, () => {
            done(tea)
        });
      });
    }
  });

  // Executed right after putting on a kettle:
  addTeaLeaves(teapot);
}
Enter fullscreen mode Exit fullscreen mode

It's only 3 levels deep, but the nesting is already becoming excessive.

The code isn't overly complicated, but it's already unpleasant to read, because of the nesting. Now consider that in real-world applications we need to deal with asynchronous code quite a bit, and you will realize why it soon feels like being in hell.

😅 This pyramid shape that's formed by nesting callbacks is affectionately called 'the pyramid of doom', because of the indentation that looks like a sideways pyramid.

Promises

A Promise is a JavaScript object representing a value that will be available after an asynchronous operation completes. Promises can be returned synchronously like regular values, but the value may be supplied at a later point.

It also provides an API to access the "promised" value by binding callbacks for different promise states.

const promise = new Promise((resolve, reject) => {
  // This function runs an asyncronous operation that will call:
  // resolve(value) - if it succeeded, with the resolved value;
  // reject(reason) - if it failed, with the error set as value;
});
Enter fullscreen mode Exit fullscreen mode

Promise states

Depending on the outcome of the asynchronous operation, promises can be in three states:

  • pending: set initially, while the asynchronous operation is in progress;
  • fulfilled: the asynchronous operation has finished successfully;
  • rejected: the operation has failed.
new Promise((resolve, reject) => {
  setTimeout(() => resolve("done!"), 1000);
});
Enter fullscreen mode Exit fullscreen mode

The promise is pending for 1 second, then it's fulfilled with the string value "done!".

When a promise enters either fulfilled or rejected state it is said to be settled. The promise is formally said to be resolved if it is settled or resolved with a promise so that further resolving or rejecting it has no effect. However, colloquially it is often meant that the promise is fulfilled.

📚 If you're interested in formal definitions, check out the ECMAScript specification and Promise/A+ specification.

Working with promises

A promise acts as a synchronous value - it can be passed as an argument or returned by functions as usual. However, to read its eventual value you need to "subscribe" to it. To allow that, promises have public methods:

  • promise.then - is a primary method that accepts callbacks to run on success and failure;
  • promise.catch - is a method to register callbacks for when the promise is rejected;
  • promise.finally - is a method to register callbacks to be executed after the promise is fulfilled or rejected.

This is how it looks in practice:

const promise = new Promise((resolve, reject) => {
  // run async code
})

promise.then(
  (value) => { /* handle a fulfilled promise result */ },
  (reason) => { /* handle a rejected promise result */ }
)

promise.catch(
  (reason) => { /* handle a rejected promise result */ },
)

promise.finally(
  () => { /* handle a any result */ }
)
Enter fullscreen mode Exit fullscreen mode

As long as the promise is pending, the callbacks will not be executed. Once the promise has been resolved, it will always hold the result value. In case a handler is attached to a resolved handler, the handler simply runs right away and receives the promise value.

Promise chains

A very useful feature of promises is that .then, .catch and .finally calls return promises, which means that we can attach multiple handlers by simply stringing the calls together:

const promise = new Promise((resolve, reject) => {
  // handle async code
});

promise
  .then(handleFulfilledA, handleRejectedA)
  .then(handleFulfilledB)
  .then(handleFulfilledC, handleRejectedC)
  .catch(handleRejectedAny)
  .finally(handleResolved)
Enter fullscreen mode Exit fullscreen mode

Each handleFulfilled handler will receive the result returned of calling the previous handler. In case an error is thrown in either hander, the promise will enter the rejected state and any further handlers called will be handleRejected and .catch.

Note: a common beginner mistake is to attach multiple handlers on the original promise when trying to chain, it won't work, but it may be a useful feature in some cases:

const promise = new Promise((resolve, reject) => {
  resolve(1);
});

promise.then((result) => {
  console.log(result); // 1
  return result + 1;
});

promise.then((result) => {
  console.log(result); // 1
  return result + 1;
});

promise.then((result) => {
  console.log(result); // 1
  return result + 1;
});
Enter fullscreen mode Exit fullscreen mode

Every .then call returns a new promise with the incremented value, but the handlers are attached to the original promise which was resolved with value 1.

Remember our function that made tea? Let's see how it looks when rewritten to use promises:

function makeTea() {
  const teapot = getTeapot();
  const kettle = getKettle();

  kettle.fill();

  const teaPromise = kettle
    .boil() // Assuming boil function returns a promise
    .catch((error) => {
      // Only handles kettle issues
      // Rethrows the error keep the rejected the promise state
      throw new Error("Kettle broke 😭");
    })
    .then(water => {
      kettle.pour(water, teapot);
      return waitMinutes(5); // Assuming waitMinutes returns a promise
    }).then(() => {
      const cup = getCup();
      const tea = teapot.pourInto(cup);
      return waitMinutes(5)
        .then(() => tea); // Our promise chain must return the cup of tea
    });

  addTeaLeaves(teapot);

  return teaPromise;
}
Enter fullscreen mode Exit fullscreen mode

Frankly, it's still not that pretty, but there's less nesting and it doesn't keep going deeper, because the promises are being chained onto the original promise for the most part.

Static methods

Promises have a couple of static methods to help make working with them easier. The most important ones are:

  • Promise.resolve(value) - creates a resolved promise with the provided value;
  • Promise.reject(reason) - creates a rejected promise with the provided value;
  • Promise.race([...promises]) - creates a promise with the value of the first fulfilled or rejected promise in the provided array;
  • Promise.any([...promises]) - creates a promise with the value of the first fulfilled promise in the array, if none are, then the promise is rejected.
  • Promise.all([...promises]) - creates a promise that resolves to an array of the fulfilled promise values. If any promise in the array rejects, the returned promise is rejected.
  • Promise.allSettled([...promises]) - creates a promise that's resolved when all of the promises in the array have been resolved (fulfilled or rejected). As a value, it receives an array of objects, that contain status and value or reason properties, representing the outcomes of each promise.

To really understand how promises work, I recommend you to understand how they can be implemented. I guarantee that most frontend developers won't be able to implement promises, simply because they don't understand them.

Well, now you will.

Implementing the Basic Promise API

Promises are the foundation for doing asynchronous work in JavaScript, so it's imperative to understand them completely. When I was just beginning with JavaScript, some aspects of promises seemed magical. However, that only pointed to my incomplete understanding.

The best way to dispel any magic is to build something from scratch. That way, we can understand how things work under the hood. Trust me when I say this - if you learn how to implement the promise API from scratch you will understand them better than most software developers.

For our purposes, I'll call our promise Futurable, with otherwise the same API.

1) Tests

Let's first start by writing some tests, so that we're sure we cover the complete API. We'll not spend time on this part, so if you're following along just copy-paste the code. We want to test at least these cases:

  1. The promise can be resolved with a value;
  2. The promise can be rejected by calling reject() or throwing an error;
  3. The promise can be rejected or resolved with another promise;
  4. Resolved and rejected promises can't be further rejected or resolved;
  5. Multiple then(), catch(), finally() handlers can be chained;
  6. finally() must be invoked on either rejection or fulfillment.
  7. Promise operations should always be asynchronous;

Here's the test suite I will be using:

import Futurable from "./Futurable";

describe("Futurable <constructor>", () => {
  it(`returns a promise-like object,
      that resolves it's chain after invoking <resolve>`, (done) => {
    new Futurable<string>((resolve) => {
      setTimeout(() => {
        resolve("testing");
      }, 20);
    }).then((val) => {
      expect(val).toBe("testing");
      done();
    });
  });

  it("is always asynchronous", () => {
    let value = "no";
    new Futurable<string>((resolve) => {
      value = "yes;";
      resolve(value);
    });
    expect(value).toBe("no");
  });

  it("resolves with the returned value", (done) => {
    new Futurable<string>((resolve) => resolve("testing")).then((val) => {
      expect(val).toBe("testing");
      done();
    });
  });

  it("resolves a Futurable before calling <then>", (done) => {
    new Futurable<string>((resolve) =>
      resolve(new Futurable((resolve) => resolve("testing")))
    ).then((val) => {
      expect(val).toBe("testing");
      done();
    });
  });

  it("resolves a Futurable before calling <catch>", (done) => {
    new Futurable<string>((resolve) =>
      resolve(new Futurable((_, reject) => reject("fail")))
    ).catch((reason) => {
      expect(reason).toBe("fail");
      done();
    });
  });

  it("catches errors from <reject>", (done) => {
    const error = new Error("Why u fail?");

    new Futurable((_, reject) => {
      return reject(error);
    }).catch((err: Error) => {
      expect(err).toBe(error);
      done();
    });
  });

  it("catches errors from <throw>", (done) => {
    const error = new Error("Why u fail?");

    new Futurable(() => {
      throw error;
    }).catch((err) => {
      expect(err).toBe(error);
      done();
    });
  });

  it("does not change state anymore after promise is fulfilled", (done) => {
    new Futurable((resolve, reject) => {
      resolve("success");
      reject("fail");
    })
      .catch(() => {
        done.fail(new Error("Should not be called"));
      })
      .then((value) => {
        expect(value).toBe("success");
        done();
      });
  });

  it("does not change state anymore after promise is rejected", (done) => {
    new Futurable((resolve, reject) => {
      reject("fail");
      resolve("success");
    })
      .then(() => {
        done.fail(new Error("Should not be called"));
      })
      .catch((err) => {
        expect(err).toBe("fail");
        done();
      });
  });
});

describe("Futurable chaining", () => {
  it("resolves chained <then>", (done) => {
    new Futurable<number>((resolve) => {
      resolve(0);
    })
      .then((value) => value + 1)
      .then((value) => value + 1)
      .then((value) => value + 1)
      .then((value) => {
        expect(value).toBe(3);
        done();
      });
  });

  it("resolves <then> chain after <catch>", (done) => {
    new Futurable<number>(() => {
      throw new Error("Why u fail?");
    })
      .catch(() => {
        return "testing";
      })
      .then((value) => {
        expect(value).toBe("testing");
        done();
      });
  });

  it("catches errors thrown in <then>", (done) => {
    const error = new Error("Why u fail?");

    new Futurable((resolve) => {
      resolve();
    })
      .then(() => {
        throw error;
      })
      .catch((err) => {
        expect(err).toBe(error);
        done();
      });
  });

  it("catches errors thrown in <catch>", (done) => {
    const error = new Error("Final error");

    new Futurable((_, reject) => {
      reject(new Error("Initial error"));
    })
      .catch(() => {
        throw error;
      })
      .catch((err) => {
        expect(err).toBe(error);
        done();
      });
  });

  it("short-circuits <then> chain on error", (done) => {
    const error = new Error("Why u fail?");

    new Futurable(() => {
      throw error;
    })
      .then(() => {
        done.fail(new Error("Should not be called"));
      })
      .catch((err) => {
        expect(err).toBe(error);
        done();
      });
  });

  it("passes value through undefined <then>", (done) => {
    new Futurable((resolve) => {
      resolve("testing");
    })
      .then()
      .then((value) => {
        expect(value).toBe("testing");
        done();
      });
  });

  it("passes value through undefined <catch>", (done) => {
    const error = new Error("Why u fail?");

    new Futurable((_, reject) => {
      reject(error);
    })
      .catch()
      .catch((err) => {
        expect(err).toBe(error);
        done();
      });
  });
});

describe("Futurable <finally>", () => {
  it("it is called when Futurable is resolved", (done) => {
    new Futurable((resolve) => resolve("success")).finally(() => {
      done();
    });
  });

  it("it is called when Futurable is rejected", (done) => {
    new Futurable((_, reject) => reject("fail")).finally(() => {
      done();
    });
  });

  it("it preserves a resolved promise state", (done) => {
    let finallyCalledTimes = 0;

    new Futurable((resolve) => resolve("success"))
      .finally(() => {
        finallyCalledTimes += 1;
      })
      .then((value) => {
        expect(value).toBe("success");
        expect(finallyCalledTimes).toBe(1);
        done();
      });
  });

  it("it preserves a rejected promise state", (done) => {
    let finallyCalledTimes = 0;

    new Futurable((_, reject) => reject("fail"))
      .finally(() => {
        finallyCalledTimes += 1;
      })
      .catch((reason) => {
        expect(reason).toBe("fail");
        expect(finallyCalledTimes).toBe(1);
        done();
      });
  });
});
Enter fullscreen mode Exit fullscreen mode

👉 If you want to follow along, feel free to create a typescript codesandbox and paste this code. Or fork mine and delete the Futurable.ts file if you want to start fresh. Switch to "Tests" tab to see the results.

2) The interface

Now we move on to the implementation, and the first thing we need to do is to define the public API.

class Futurable<T> {
  constructor(
    executor: (
      resolve: (value: T | Futurable<T>) => void,
      reject: (reason?: any) => void
    ) => void
  ) {
    // TODO: implement
  }

  then = <R1 = T, R2 = never>(
    onFulfilled?: (value: T) => R1 | Futurable<R1>,
    onRejected?: (reason: any) => R2 | Futurable<R2>
  ) => {
    // TODO: implement
  };

  catch = <R = never>(onRejected?: (reason: any) => R | Futurable<R>) => {
    // TODO: implement
  };

  finally = (onFinally: () => void): Futurable<T> => {
    // TODO: implement
  };
}

export default Futurable<T>;
Enter fullscreen mode Exit fullscreen mode

I took the types mostly from the ES6 promise type definitions so that our implementation gets as close to the standard as possible.

Our constructor will take the callback as a parameter and call it passing resolve and reject functions.

Remember how I mentioned that then() accepts callbacks for both fulfillment and rejection? Well, that's what we define here. It's going to be the basis for registering our handlers - catch and finally is only 'sugar', as you will see later.

3) State machine

I've already mentioned how a promise has 3 states: fulfilled, rejected, and pending. This can be expressed as a state machine:

image.png

The promise starts off as pending and moves into resolved state or rejected state. After it makes the transition, it can no longer be resolved, rejected, or enter the pending state.

To implement this, first, we need to define the internal state and the result of the promise, let's do that:

enum FuturableState {
  Pending,
  Resolved,
  Rejected
}

class Futurable<T> {
  private state = FuturableState.Pending;
  private result?: any;

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Now we can implement a function that handles these transitions:

const isThenable = (obj: any): obj is Futurable<any> =>
  typeof obj?.then === "function";

class Futurable<T> {
  // ...

  private resolve = (value: T | Futurable<T>) => {
    // TODO: implement
  };

  private reject = (reason?: any) => {
    // TODO: implement
  };

  private setResult = (value: any, state: FuturableState) => {
    if (this.state !== FuturableState.Pending) {
      return;
    }

    if (isThenable(value)) {
      value.then(this.resolve, this.reject);
      return;
    }

    this.state = state;
    this.result = value;
  };
}
Enter fullscreen mode Exit fullscreen mode

The setResult method is now responsible for making a transition and setting the promise result, let's understand what the code does.

  1. We only need to allow transitions from pending state, otherwise, calling setResult will do nothing, so we exit the function by returning.
  2. We check if the value provided was in itself a promise and resolve it instead of transitioning the state. We also defined the resolve and reject internal functions, which we'll implement later.
  3. If the value was not a promise, we transition the state and value.

📌 According to the promise standard, if an object contains a then method, then it should act like a promise, even if it isn't an instance of a Promise class - it's called a thenable. Now that's pretty cool! That means, that we could resolve Futurable with new Promise(...) as a result and it would still work, and vice versa.

4) Resolve / Reject

Now that we have a function that handles our promise's state transitions, we can use it to implement the constructor and resolve/reject functions. It's as simple as calling our setResult function in resolve and reject function and then using them in our constructor:

  constructor(
    executor: (
      resolve: (value: T | Futurable<T>) => void,
      reject: (reason?: any) => void
    ) => void
  ) {
      executor(this.resolve, this.reject);
    } catch (e) {
      this.reject(e);
    }
  }

  // ...

  private resolve = (value: T | Futurable<T>) => {
    this.setResult(value, FuturableState.Resolved);
  };

  private reject = (reason?: any) => {
    this.setResult(reason, FuturableState.Rejected);
  };
Enter fullscreen mode Exit fullscreen mode

You may notice the try-catch block and wonder why we need it in the constructor. That's because calling reject is not the only way to reject a promise, we can also throw an error. Doing that will trigger our catch block, and we handle the error by rejecting the promise.

Now we have the ability to create a new Futurable promise and run the callback. Resolving or rejecting it in the callback will change the internal promise state and set the value:

const resolvedPromise = new Futurable((resolve, reject) => {
  resolve('success!')
})

const rejectedPromise = new Futurable((resolve, reject) => {
  reject('fail...')
})
Enter fullscreen mode Exit fullscreen mode

However, there's one issue...

5) Executing promises asynchronously

Constructing a new promise will immediately execute the callback. This means that our Promises are blocking the code execution, which means they're not actually asynchronous operations.

If you want to see the problem in action, run this code:

let called = false;

new Futurable((resolve) => {
  called = true;
  resolve();
});

console.log(called); // Prints: "true"
Enter fullscreen mode Exit fullscreen mode

The constructor is being run synchronously, asynchronous code would not run the callback immediately.

Let's fix that.

Without going deep into how the event loop works, we should at least know that our code is being run synchronously in these phases:

  • Executing tasks - your code;
  • Executing jobs - native ES6 Promise callbacks;
  • Executing messages - events, callbacks, ajax requests, etc.

If you want a thorough explanation of how the event loop works, watch this excellent presentation:

%[https://www.youtube.com/watch?v=8aGhZQkoFbQ]

What this means in practice is that your code is run synchronously at the highest priority - as long as it's running, it will block the execution of any code that follows. When the code execution phase is over, it will process any jobs and messages that have been received in the meanwhile.

😉 If you want to see what I mean by blocking code, run (true) { console.log('blocking'); console.log('not blocking'); } in your browser console for example. You will never see the message 'not blocking'. Do it at your own peril though, you may not be able to close the tab anymore and may have to kill the browser.

Since we're doing a custom implementation of the promises, we assume that there's no native support for Promises, but we still need to execute the Futurable callbacks asynchronously. To do that we can utilize a well-known hack: setTimeout(callback, 0);

This would delegate the execution of the callback to run immediately after any synchronous code, hence zero milliseconds. Let's write a utility function:

const runAsync = (callback: () => void) => {
  setTimeout(callback, 0);
};
Enter fullscreen mode Exit fullscreen mode

We can now wrap our constructor and execute code with this utility to run them asynchronously as such:

// ...

constructor(
  executor: (
    resolve: (value: T | Futurable<T>) => void,
    reject: (reason?: any) => void
  ) => void
) {
  runAsync(() => {
    // ...
  });
}

private setResult = (value: any, state: FuturableState) => {
  runAsync(() => {
    // ...
  }
};

// ...
Enter fullscreen mode Exit fullscreen mode

If you test the previous example again, you will see that it prints false, which means that the promise callback code isn't run immediately and doesn't block the code execution anymore.

6) Executing handlers

Promises allow us to access their value by attaching then or catch handlers and providing them with callbacks which then receive value asynchronously. Because the callbacks are supposed to be executed one by one sequentially, we can put them in a queue.

type Handler<T> = {
  handleThen: (value: T) => void;
  handleCatch: (reason?: any) => void;
};

export default class Futurable<T> {
  private handlers: Handler<T>[] = [];

  // ...
}
Enter fullscreen mode Exit fullscreen mode

How this is going to work:

  1. Whenever .then or .catch is called, we'll push the callback handler into a queue. There will always be a handler for both, except one of them will simply pass the promise value through as a result, like this (value) => value. This will ensure, that the whole chain of handlers gets executed, but irrelevant handlers are "skipped", e.g. skip all .catch calls in case there was no error.
  2. We will execute all of the handlers when the promise is fulfilled or rejected.
  3. We will execute the resolvers as soon as a handler is pushed into the queue, in case the handler is attached to the promise that has already been resolved. However, we should remember not to execute the handlers until the promise has resolved.
  4. We will remove all handlers as soon as they finished executing.

Let's write the function to execute the handlers first:

private executeResolvers = () => {
  if (this.state === FuturableState.Pending) {
    return;
  }

  this.handlers.forEach((resolver) => {
    if (this.state === FuturableState.Resolved) {
      resolver.handleThen(this.result);
    } else {
      resolver.handleCatch(this.result);
    }
  });

  this.handlers = [];
};

private setResult = (value: any, state: FuturableState) => {
  // ...
  this.executeResolvers();
};
Enter fullscreen mode Exit fullscreen mode

First, we check if the promise has resolved already, if it hasn't we skip it. We only want to execute .then and .catch handlers only on resolved promises.

Then we run all of the handlers for .then or .catch depending on whether the promise is resolved or rejected. Afterward, we clean all of the handlers so that they don't get executed again.

Finally, we need to run the resolvers as soon as we set the result of the promise, which will execute the promise chain.

7) Implementing .then

We can run the handlers, but we still need a way to push the handlers to the queue. That's where .then handler comes in:

then = <R1 = T, R2 = never>(
  onFulfilled?: null | ((value: T) => R1 | Futurable<R1>),
  onRejected?: null | ((reason: any) => R2 | Futurable<R2>)
) => {
  return new Futurable<R1 | R2>((resolve, reject) => {
    this.resolvers.push({
      handleThen: (value) => {
        if (!onFulfilled) {
          resolve(value as any);
        } else {
          try {
            resolve(onFulfilled(value) as R1 | Futurable<R1 | R2>);
          } catch (e) {
            reject(e);
          }
        }
      },
      handleCatch: (reason) => {
        if (!onRejected) {
          reject(reason);
        } else {
          try {
            resolve(onRejected(reason) as R2 | Futurable<R1 | R2>);
          } catch (e) {
            reject(e);
          }
        }
      }
    });
    this.executeResolvers();
  });
};
Enter fullscreen mode Exit fullscreen mode

The code looks daunting, but it is actually pretty simple when broken down.

By specification, .then is supposed to be able to handle both fulfill and reject callbacks, and the types come directly from the ES6 Promise interface. The handlers must always return a promise so that they can be chained, so as a result, we return a new Futurable.

We push .then and .catch handlers, that are basically identical, except one is calling the resolve callback, and the other one - reject.

We resolve the promise with the result returned from the callback. In case the callback is not provided, we simply pass through the original value of the promise, which allows us to chain promises even if we omit either handler, essentially replacing it with (value) => value.

Then we wrap everything in try/catch, because we should be able to reject the promise by throwing an error in the handler, as an alternative to calling reject.

Finally, as mentioned earlier, we need to execute the resolvers after adding pushing the promise into the queue, so that if the promise was already resolved, the new handler would still run.

Let's test:

new Futurable((resolve) => resolve("ok"))
  .then((value) => {
    console.log(value);
    throw new Error("fail");
  })
  .then(null, (error) => console.log(error.message));
Enter fullscreen mode Exit fullscreen mode

If you run this code, you should see both ok and fail, on the screen, which means that our Futurable promise is chainable and can handle both fulfillment and rejection.

8) Implementing .catch and .finally

We already have a fully functional Promise now, but let's add some sugar: .catch and .finally.

From the above test, you can see, that catch is equivalent to then without the onFulfill handler:

catch = <R = never>(onRejected?: (reason: any) => R | Futurable<R>) => {
  return this.then(null, onRejected);
};
Enter fullscreen mode Exit fullscreen mode

And .finally is as simple as executing a callback no matter if the promise was fulfilled or rejected:

finally = (onFinally: () => void): Futurable<T> => {
  return this.then(
    (value) => {
      onFinally();
      return value;
    },
    (reason) => {
      onFinally();
      throw reason;
    }
  );
};
Enter fullscreen mode Exit fullscreen mode

The only caveat is that .finally must not change the promise status, so it passes through whatever the result was - it returns the value if the promise was resolved or rethrows the error in case it was rejected.

You can see the full implementation here:

%[https://codesandbox.io/embed/futurable-codefrontend-com-glt0qq?fontsize=14&hidenavigation=1&theme=dark]

There we have it - the basic premise implementation we built ourselves. As a fun experiment, try to use it interchangeably with the native promise and see if it works. If you wish to refine the implementation further and deepen the understanding of promises, try to implement these static methods:

  • Promise.resolve
  • Promise.reject
  • Promise.race
  • Promise.any
  • Promise.all
  • Promise.allSettled if you really want to go advanced.

Async/await

By now you might have gathered, that promises solve some of the issues of working with asynchronous operations in JavaScript, yet they're not perfect:

  1. They don't completely eliminate the nesting;
  2. Their readability is not great;
  3. While the promise can be returned synchronously, its result can't be accessed synchronously;
  4. Regular language constructs are not straightforward to use (e.g. try/catch block).

Well, recently a more convenient way of handling asynchronous operations was introduced - async/await, and promises are the heart of it.

Async functions

When declaring a function, we can mark it as async, which will allow us to return any value the same as always, but the result would automatically be considered a promise:

async function doAsyncStuff() {
  return 1;
}

const promise = doAsyncStuff()
  .then((result) => console.log(result)) // Prints: 1
Enter fullscreen mode Exit fullscreen mode

As we see in the example, returning a value is equivalent to resolving a promise. Even if there's no value returned, the result will still be a promise, except with undefined as the result. Similarly, throwing an error is equivalent to rejecting the promise:

// Inline functions can also be async
const doAsyncStuff = async () => {
  throw new Error("don't wanna");
}

const promise = doAsyncStuff()
  .catch((error) => console.log(error.message)) // Prints: "don't wanna"
Enter fullscreen mode Exit fullscreen mode

Await

What's cool about async functions, is that inside of them you can use the await keyword to synchronously resolve promises:

async function doAsyncStuff() {
  return 1;
}

const timeoutPromise = new Promise((resolve, reject) => {
  setTimeout(() => resolve("done!"), 1000);
});

async function useAsyncStuff() {
  const asyncStuff = await doAsyncStuff();
  const timeoutResult = await timeoutPromise();
  console.log(asyncStuff, timeoutResult); // Prints: "1 done!"
}

useAsyncStuff();
Enter fullscreen mode Exit fullscreen mode

The await keyword makes the code blocking until the promise is resolved, essentially waiting for the operation to complete until it proceeds with code execution. This allows us to get results from asynchronous functions in much the same way as regular ones.

But that's not it. When you use await the error will be thrown synchronously as well, so the regular try/catch block will work:

async function doAsyncStuff() {
  throw new Error("Ah damn...");
}

async function useAsyncStuff() {
  try {
    await doAsyncStuff();
  } catch(err) {
    console.log(err); // Prints: "Ah damn..."
  }
}

useAsyncStuff();
Enter fullscreen mode Exit fullscreen mode

However, in the end, it's all promises, so async/await can be used with .then, .catch and .finally interchangeably:

async function doAsyncStuff() {
  throw new Error("Ah damn...");
}

async function useAsyncStuff() {
  await doAsyncStuff().catch(() => {
    console.log(err); // Prints: "Ah damn..."
  });
}

useAsyncStuff();
Enter fullscreen mode Exit fullscreen mode

As a final exercise, let's rewrite our tea preparation function using async/await:

async function makeTea() {
  const teapot = getTeapot();
  const kettle = getKettle();

  kettle.fill();

  const hotWaterPromise = kettle.boil().catch(() => {
    throw new Error("Kettle broke 😭");
  });

  // Add tea leaves while we wait for the water to boil, like previously
  const [water] = await Promise.all([
    hotWaterPromise,
    addTeaLeaves(teapot)
  ]);

  kettle.pour(water, teapot);
  await waitMinutes(5);

  const cup = getCup();
  const tea = teapot.pourInto(cup);

  await waitMinutes(5);

  return tea;
}
Enter fullscreen mode Exit fullscreen mode

As you see async/await has eliminated excessive nesting and is generally simpler to read and understand.

Caveat

As you can imagine, async/await solves most of the remaining issues with promises. However, there are some common mistakes that beginners run into.

You can't use await at the root level or regular functions, only in async functions:

function doAsyncStuff() {
  const promise = Promise.resolve("done!");
  const result = await promise; // Syntax error
}

const result = await new Promise((resolve) => resolve("done!")); // Syntax error
Enter fullscreen mode Exit fullscreen mode

Although, some modern JavaScript runtimes (modern browsers, latest node versions) allow async at the root level. As a workaround for older JS runtimes, wrap the call in a self-invoking anonymous async function:

(async () => {
  const result = await new Promise((resolve) => resolve("done!")); // Works
})();
Enter fullscreen mode Exit fullscreen mode

Final thoughts

Modern JavaScript provides ways to work with asynchronous operations conveniently. However, promises are the basis of everything that's async in JavaScript, so it's essential to understand them completely.

I've shown how you would implement promises yourself and hopefully, the process has shown you that there's no magic involved when you break them down.

If it was your first involvement with promises, you might have gotten a bit overwhelmed, but don't worry, with practice, using promises will become second nature, and you can always use this post as a reference if you want to revisit the implementation with fresh eyes after a while.


This article is reposted from my blog at codefrontend.com, check it out for other deep dives and quick guides. Follow me on Twitter @VincasStonys for web development tips.

Top comments (0)