Skip to the code
cf.

A promise from scratch

Write a class MyPromise that works like a simplified Promise. new MyPromise(executor) calls executor(resolve, reject) right away. The promise starts pending; the first call to resolve(value) or reject(reason) decides its outcome, and later calls do nothing. If the executor throws before that, the promise rejects with the error.

then(onFulfilled, onRejected) returns a new MyPromise. When the promise settles, the matching callback runs with the value or the reason, and the new promise resolves with what the callback returns, or rejects with what it throws. If the matching callback isn't a function, the value or reason passes through to the new promise unchanged. catch(onRejected) is then(undefined, onRejected).

Callbacks always run asynchronously, in a microtask (use queueMicrotask), even if the promise has already settled. Callbacks added to the same promise run in the order they were added.

Resolving with a thenable (any object or function with a then method, a MyPromise included), whether through resolve or by returning it from a callback, makes the promise follow it: it settles the way the thenable does.

Also add MyPromise.resolve(value), which returns value itself if it's already a MyPromise and otherwise a promise resolved with it, and MyPromise.reject(reason), which returns a promise rejected with reason. You can leave out finally, the static combinators, and the check for a promise resolved with itself.

new MyPromise((resolve) => setTimeout(() => resolve(2), 100))
  .then((n) => n * 10)
  .then((n) => {
    throw new Error("too big: " + n);
  })
  .catch((error) => error.message)
  .then((message) => console.log(message)); // "too big: 20"

What the tests check

  1. runs the executor right away, with resolve and reject
    let received;
    new MyPromise((...args) => {
      received = args;
    });
    expect(received).toBeDefined();
    expect(received.map((arg) => typeof arg)).toEqual(["function", "function"]);
    
  2. runs then callbacks in a microtask, even when already settled
    const log = [];
    setTimeout(() => log.push("timer"), 0);
    new MyPromise((resolve) => resolve("value")).then((value) => log.push(value));
    log.push("sync");
    await flush();
    expect(log).toEqual(["sync", "value", "timer"]);
    
  3. calls every callback added while pending, in order, once it settles
    let resolve;
    const promise = new MyPromise((r) => {
      resolve = r;
    });
    const log = [];
    promise.then((value) => log.push("first " + value));
    promise.then((value) => log.push("second " + value));
    await flush();
    expect(log).toEqual([]);
    resolve("done");
    await flush();
    expect(log).toEqual(["first done", "second done"]);
    
  4. settles only once
    const onFulfilled = spy();
    const onRejected = spy();
    new MyPromise((resolve, reject) => {
      resolve(1);
      resolve(2);
      reject(new Error("too late"));
      throw new Error("also too late");
    }).then(onFulfilled, onRejected);
    await flush();
    expect(onFulfilled).toHaveBeenCalledTimes(1);
    expect(onFulfilled).toHaveBeenCalledWith(1);
    expect(onRejected).not.toHaveBeenCalled();
    
  5. rejects when the executor throws
    const error = new Error("boom");
    const onRejected = spy();
    new MyPromise(() => {
      throw error;
    }).then(undefined, onRejected);
    await flush();
    expect(onRejected).toHaveBeenCalledTimes(1);
    expect(onRejected.calls[0].args[0]).toBe(error);
    
  6. chains values through then, skipping missing callbacks
    const start = new MyPromise((resolve) => resolve(2));
    const next = start.then((n) => n * 10);
    expect(next).toBeInstanceOf(MyPromise);
    expect(next).not.toBe(start);
    const done = spy();
    next
      .then()
      .then((n) => n + 1)
      .then(done);
    await flush();
    expect(done).toHaveBeenCalledWith(21);
    
  7. turns a thrown error into a rejection that catch can handle
    const error = new Error("failed");
    const skipped = spy();
    const done = spy();
    new MyPromise((resolve) => resolve(1))
      .then(() => {
        throw error;
      })
      .then(skipped)
      .catch((reason) => (reason === error ? "recovered" : "wrong reason"))
      .then(done);
    await flush();
    expect(skipped).not.toHaveBeenCalled();
    expect(done).toHaveBeenCalledWith("recovered");
    
  8. follows a thenable or MyPromise it's resolved with
    const fromCallback = spy();
    const fromExecutor = spy();
    const onRejected = spy();
    const error = new Error("inner failed");
    new MyPromise((resolve) => resolve(1))
      .then(() => new MyPromise((resolve) => resolve("inner")))
      .then(fromCallback);
    const thenable = {
      then(onFulfilled) {
        onFulfilled("from a thenable");
      },
    };
    new MyPromise((resolve) => resolve(thenable)).then(fromExecutor);
    new MyPromise((resolve) => resolve(1))
      .then(() => new MyPromise((_, reject) => reject(error)))
      .then(undefined, onRejected);
    await flush();
    expect(fromCallback).toHaveBeenCalledWith("inner");
    expect(fromExecutor).toHaveBeenCalledWith("from a thenable");
    expect(onRejected).toHaveBeenCalledTimes(1);
    expect(onRejected.calls[0].args[0]).toBe(error);
    
  9. MyPromise.resolve and MyPromise.reject make settled promises
    const existing = new MyPromise(() => {});
    expect(MyPromise.resolve(existing)).toBe(existing);
    const onFulfilled = spy();
    const onRejected = spy();
    MyPromise.resolve(5).then(onFulfilled);
    MyPromise.reject("no").then(undefined, onRejected);
    await flush();
    expect(onFulfilled).toHaveBeenCalledWith(5);
    expect(onRejected).toHaveBeenCalledWith("no");
    

Learn more

solution.jsSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).