Cycling Promise in TypeScript

The idea to write my own implementation of Promise arose in the process of preparing for the interview, since the need to not only understand the tool, but to recreate its more or less exact likeness, requires a much deeper immersion in the topic. The source code with tests is available at linkthis article is an opportunity for the author to consolidate the experience gained in the process even better and, perhaps, discover something new for the reader who regularly uses promises in practice.

Glossary

  • Instance, instance, promise, promise – created new PromiseImplementation(...) an object.

  • Consumer, subscriber – any public instance method, .then | .catch | .finally.

  • Satisfyer – private method resolve or reject an instance whose calling is equivalent to fulfilling the promise.

  • Promise fulfillment – change state instance from pending to “successful” or “rejected”, writing the satisfyer argument to result instance.

types

From what is well known to everyone who has ever used new Promise(...) in practice – a promise has only 3 public methods: .then, .catch and .finally and at least 2 private properties, state and result.

The latter can be immediately defined in the class: initial state instance, it is always waiting, “pending”, which, depending on the course of the object’s life cycle, can change 1 time to ‘fulfilled’ or ‘rejected’, but the result can be anything.

export type PromiseState="pending" | 'fulfilled' | 'rejected';
export type PromiseResult = any;
import { PromiseState, PromiseResult } from './types';

export default class PromiseImplementation {
    private state: PromiseState="pending";
    private result: PromiseResult;
}

constructor

The constructor takes a PromiseExecutor function as input and straightaway calls her. Binded satisfyers are passed as arguments.

export type ExecutorCallback = (argument?: any) => void;
export type PromiseExecutor = (resolve: ExecutorCallback, reject: ExecutorCallback) => any;

This organization will allow the end user of the class to define the code that will immediately start executing and have access to control the state of the created promise in this code.

export default class PromiseImplementation {
    private state: PromiseState="pending";
    private result: PromiseResult;

    constructor(executor?: PromiseExecutor) {
        if (typeof executor !== 'function') {
            throw new Error('Invalid executor is provided.');
        }

        try {
            executor(this.resolve.bind(this), this.reject.bind(this));
        } catch (err) {
            this.reject(err);
        }
    }
}

The construction is wrapped in try..catch so that even in case of an error in the executor, a full-fledged instance is created. This will allow the end user to handle the exception in .catch instead of crashing the entire subscriber chain.

There is only one exception, the absence of an executor in principle, in which case an instance is not created.

Satisfyers

The implementation of these methods implies the following:

  • Calling the method entails changing the state & result of the instance, that is, the fulfillment of the promise. This option should only be provided one once.

  • If the promise is followed by consumer s, their arguments must be called after fulfillment of a promise.

In the example (1)

const promise = new PromiseImplementation((resolve) => {
	setTimeout(() => resolve(1), 1000);
});

promise.then((x) => {
	console.log(x);
});

promise.then(
 (x) => {
	console.log(x * 2);
 },
 (err) => {
	console.log(err);
 }
);

promise.then();

call resolve after 1 second should change promise['state'] to ‘fulfilled’, promise['result'] by 1 and cause callbacks to be called

(x) => { console.log(x); }
(x) => { console.log(x * 2); }

c promise['result'] as x.

Subscribers themselves .then on the lines 5, 9, 18of course, do not wait for deferred execution resolvethese are normal methods, each of which will be called in sequence immediately after instantiation.

At this stage, the first outlines for future implementation are outlined. .thenand its relationship with resolve & reject:

  • In the consumer, it is necessary to check the state of the instance, if the promise is not fulfilled, custom handlers are not launched, but are stored until required.

  • AT resolve & rejectafter modifying the state of the instance, it is necessary to launch the consumers in the same order in which the user placed them, passing the saved callbacks to them.

Let’s define a storage field:

export type ConsumerCallback = (argument?: any) => any;
export default class PromiseImplementation {
    private state: PromiseState="pending";
    private result: PromiseResult;

    constructor(executor?: PromiseExecutor) {/* ... */}
  
  	private consumersArgs: ConsumerCallback[][] = [];
}

For example (1), promise['consumersArgs'] after the line 18 should look like this:

[
	[handleSuccess, undefined], 
  [handleSuccess, handleError], 
  [undefined, undefined]
] 

The callbacks will be stored in the same order in which the subscribers are executed, and will be called in the same sequence after the promise is fulfilled, through a loop:

export default class PromiseImplementation {
    private state: PromiseState="pending";
    private result: PromiseResult;

    constructor(executor?: PromiseExecutor) {/* ... */}
  
  	private consumersArgs: ConsumerCallback[][] = [];
  
   	private resolve(value?: any) {
        if (this.state === 'pending') {
            this.state="fulfilled";
            this.result = value;
          
            if (this.consumersArgs.length) {
                for (let consumerArgs of this.consumersArgs) {
                    this.then(...consumerArgs);
                }
            }
        }
    }
}

The code above would fully meet the requirements, if not for 1 infrequent case when resolve called with a promise instance as an argument.

const original = new PromiseImplementation(resolve => setTimeout(() => resolve('originalResult'), 1000));
const cast = new PromiseImplementation((resolve, reject) => resolve(original));

cast.then((x) => {
    console.log(x); // 'originalResult'
});

The cast promise will only be executed when the promise is fulfilled originaland will be executed with the value original['result']not by ourselves originalas it might seem at first glance. With this in mind, it makes sense to move the change in the state of an instance and calling its subscribers into a separate method, and give the end user a wrapper with additional checks:

export default class PromiseImplementation {
    private state: PromiseState="pending";
    private result: PromiseResult;

    constructor(executor?: PromiseExecutor) {/* ... */}
  
  	private consumersArgs: ConsumerCallback[][] = [];
  
   	private applyResolveMainLogic(value?: any) {
        if (this.state === 'pending') {
            this.state="fulfilled";
            this.result = value;
          
            if (this.consumersArgs.length) {
                for (let consumerArgs of this.consumersArgs) {
                    this.then(...consumerArgs);
                }
            }
        }
    }
  
  	private resolveCallsCount = 0;
  	private resolve(value?: any) {
        if (this.resolveCallsCount > 0 || this.rejectCallsCount > 0) {
            this.resolveCallsCount += 1;
            return;
        }

        this.resolveCallsCount += 1;

        if (value instanceof PromiseImplementation) {
            value.then(
                (result) => this.applyResolveMainLogic(result),
                (err) => this.applyRejectMainLogic(err)
            );

            return;
        }

        this.applyResolveMainLogic(value);
    }
}

The final method can only be called once (line 26). If a promise is passed as an argument to the method, only after it has been fulfilled (line 34) and with its same result will it be called applyMainLogic original copy.

Method reject looks somewhat simpler, since its call is always change state to ‘rejected’ and result on the value of the argument, regardless of its type:

export default class PromiseImplementation {
    private state: PromiseState="pending";
    private result: PromiseResult;

    constructor(executor?: PromiseExecutor) {/* ... */}
  
  	private consumersArgs: ConsumerCallback[][] = [];
  
   	private applyResolveMainLogic(value?: any) {/* ... */}
  
  	private resolveCallsCount = 0;
  	private resolve(value?: any) {/* ... */}
  
  	private applyRejectMainLogic(error?: any) {
        if (this.state === 'pending') {
            this.state="rejected";
            this.result = error;

            if (this.consumersArgs.length) {
                for (let consumerArgs of this.consumersArgs) {
                    this.then(...consumerArgs);
                }
            }
        }
    }

    private rejectCallsCount = 0;
    private reject(error?: any) {
        if (this.rejectCallsCount > 0 || this.resolveCallsCount > 0) {
            this.rejectCallsCount += 1;
            return;
        }

        this.rejectCallsCount += 1;

        this.applyRejectMainLogic(error);
    }
}

Consumers

Some sketches have already been made in the previous section: custom handlers passed as arguments to the subscriber need only be run if the promise has already been fulfilled, otherwise the arguments need to be saved until the moment it happens.

Also, two more key points can be immediately distinguished:

  • Transferred to .then | .catch | .finally callbacks always are called asynchronously. From a practical point of view, this means that the callbacks and some auxiliary logic will be wrapped in setTimeout with zero delay.

  • The ability to chain calls means that each subscriber must almost always return a new instance of the promise.

You can immediately highlight the problem arising from the last paragraph. Example (2):

/* экземпляр_0 */
new PromiseImplementation(resolve => {
    setTimeout(() => {
        resolve(1);
    }, 1000);
})
/* then_0, вызывается на экземпляр_0, возвращает экземпляр_1 */
.then(x => {
    return x * 2;
})
/* then_1, вызывается на экземпляр_1, возвращает экземпляр_2 */
.then((x) => {
    return x * 4;
})

The organization of such a chain of calls leads to the creation of three promises. Moreover, the execution of only one is explicitly declared, instances one and 2 do not modify their own state by themselves.

method logic .then must be implemented in such a way that:

  • .then_0when the promise 0 done, called custom handler
    x => { return x * 2; } and kept the promise one with the result of this handler.

  • then_1when the promise one done, called custom handler
    x => { return x * 4; } and kept the promise 2 with the result of this handler.

  • …And so on until the end of any chain.

In other words, the instance on which the subscriber is called must be able to fulfill the promise that the subscriber returns.

Let’s add some new fields to the class:

export default class PromiseImplementation {
  	/* Вспомогательные структуры */
  	private currentConsumerIndex = 0;
    private consumersArgs: ConsumerCallback[][] = [];
    private consumerShouldReturnInstance = true;
    private consumersInstanceSettlers: {
        resolvers: ExecutorCallback[];
        rejecters: ExecutorCallback[];
    } = {
        resolvers: [],
        rejecters: [],
    };
}

FieldconsumersInstanceSettlers will contain satisfayers of instances returned by subscribers.

Multiple subscribers can be called on the same instance. After the promise is fulfilled, they are called sequentially in a loop, the saved callbacks are passed as arguments, but not the iteration index. FieldcurrentConsumerIndex necessary for this very reason, in order to navigate the structure consumersInstanceSettlers.

FlagconsumerShouldReturnInstance necessary to not return an instance from the subscriber if it was called in resolve or reject, that is, programmatically, not by the user. Otherwise, the data in consumersInstanceSettlerswill be overwritten.

export default class {
	private applyMainLogic(argument?: any) {
        if (this.state === 'pending') {
            this.state = /* fulfilled | rejected */;
            this.result = argument;

            if (this.consumersArgs.length) {
              	/* Не возвращать экземпляр, если .then вызывается программно */
                this.consumerShouldReturnInstance = false;

                for (let consumerArgs of this.consumersArgs) {
                    this.then(...consumerArgs);
                }
            }
        }
    }
}

Now you can implement a public method .then:

export default class PromiseImplementation {
  	then = (handleSuccess?: ConsumerCallback, handleError?: ConsumerCallback) => {
      /* Вызов без аргументов - возвращаем этот же экземляр */  
      if (!handleSuccess && !handleError) {
            return this;
        }

        const { state, result } = this;
        const isSettled = state !== 'pending' && 'result' in this;

        if (isSettled) {
          	/* 
            	Если обещание выполнено - ставим таймер 
            	с нулевой задержкой на выполнение пользовательских хендлеров
            */
            setTimeout(() => {
                if (handleSuccess === handleError) {
                    this.handleFinally(result, handleSuccess || handleError, state);

                    return;
                }

                if (state === 'fulfilled') {
                    this.handleResult(result, handleSuccess, state);
                }

                if (state === 'rejected') {
                    this.handleResult(result, handleError, state);
                }
            }, 0);
        }

        if (this.consumerShouldReturnInstance) {
            if (!isSettled) {
                /* 
                	Сохраняем пользовательские обработчики до востребования 
                */
                this.consumersArgs.push([handleSuccess, handleError]);
            }

          	/* 
            	По умолчанию возвращаем новый инстанс, и сохраняем его
              satisfayer - ы в текущий. 
            */
            return new PromiseImplementation((resolve, reject) => {
                this.consumersInstanceSettlers.resolvers.push(resolve);
                this.consumersInstanceSettlers.rejecters.push(reject);
            });
        }
    };
}

Calling custom handlers and processing their results will occur in handleResult and handleFinally. They will also fulfill the promise returned by the subscriber. Receiving logic resolve & reject this promise, it makes sense to put it in a separate method:

export default class PromiseImplementation { 
  	/* Получить satisfier - ы инстанса, который вернул подписчик */
  	private getConsumerInstanceSettlers() {
        const index = this.currentConsumerIndex++;

        const resolveNext = this.consumersInstanceSettlers.resolvers[index];
        const rejectNext = this.consumersInstanceSettlers.rejecters[index];

        return {
            resolveNext,
            rejectNext,
        };
    }
}

private method handleResult:

export default class PromiseImplementation {
  	private handleResult(result: any, handler?: ConsumerCallback, state?: PromiseState) {
        /* Получение resolve & reject экземпляра, который вернул .then или .catch */
				const { resolveNext, rejectNext } = this.getConsumerInstanceSettlers();

      	/* В случае ошибки в handler, будет вызван rejectNext */
        try {
            const handlerResult = handler ? handler(result) : result;

          	/* Если handler вернул обещание, это обрабатывается особым образом */
            if (handlerResult instanceof PromiseImplementation) {
                const resolve = (result) => resolveNext(result);
                const reject = (err) => rejectNext(err);

                handlerResult.then(resolve, reject);

                return;
            }

          	/* 
            	Нет обработчика ошибки - идем по цепочка дальше, пока этот
              обработчик не встретим. 
            */
            if (state === 'rejected' && !handler) {
                rejectNext(result);

                return;
            }

          	/* Вызов resolveNext с результатом, который вернул handler */
            resolveNext(handlerResult);
        } catch (err) {
            rejectNext(err);
        }
    }
  
  	then = (handleSuccess?: ConsumerCallback, handleError?: ConsumerCallback) => {/*...*/};
}

In the event of an error in the custom handler, the promise returned by the subscriber is executed with that error. If the handler worked correctly, the promise returned by the subscriber will be fulfilled with the result returned by this handler. Of course, you need to handle the result in a special way if the result of calling the stored callback is a promise instance. In this case, it is necessary to wait for its execution, and only after that call resolve | reject the instance returned by the subscriber.

This logic is valid for consumers .then and .catchbut not for .finally. Therefore, a private method is needed handleFinally, which works in a similar way. Its main difference is that the custom handler is called without arguments. The result of calling it is also ignored unless it’s a promise, so the method has almost no effect on the chain.

private method handleFinally:

export default class PromiseImplementation {
	private handleFinally(result: any, handler?: ConsumerCallback, state?: PromiseState) {
        const { resolveNext, rejectNext } = this.getConsumerInstanceSettlers();

        try {
          	/* Пользовательский коллбек вызывается без аргументов. */
            const handlerResult = handler && handler();

            if (handlerResult instanceof PromiseImplementation) {
								/* 
                	Если результат коллбека - обещание,
                  код просто дождется его выполнения, но 
                  результат обещания будет проигнорирован.
                */
                const resolve = () => resolveNext(result);
                const reject = (err) => rejectNext(err);

                handlerResult.then(resolve, reject);

                return;
            }

            if (state === 'rejected') {
                rejectNext(result);

                return;
            }

            resolveNext(result);
        } catch (err) {
            rejectNext(err);
        }
    }
}

Received Method .then universal. Public Methods .catch and .finally are implemented as simple wrappers passing different sets of arguments.

catch = (handleError?: ConsumerCallback) => {
    return this.then(undefined, handleError);
};
finally = (callback?: ConsumerCallback) => {
    return this.then(callback, callback);
};

Instead of a conclusion

  • Examination instanceof in the code this is a simplification, under the hood of native promises it is checked that the entity is Thenable an object.

  • The same can be said about setTimeoutnative promises have its internal queue.

  • The last deliberate simplification is the use .finally like wraps over .then, called with two identical callbacks. Such a case is possible, but, in experience, vanishingly rare.

  • Thanks to the reader who made it this far. Understanding everything the first time can be difficult. A line-by-line analysis of all the processes that occur even with a small chain would inflate the material to obscenity, so just in case I leave a link to the source at the beginning of the article. I would be glad for any feedback on how to make the text clearer, or if you see flaws in the implementation.

Similar Posts

Leave a Reply