4
\$\begingroup\$

This is yet another Node.js Promise implementation, but this is written in TypeScript, and it strives for type safety and clarity. The full project is located here. Feel free to download it and test it out.

Please give your comments regarding any missing bits or errors, as well as any potential improvements (I'm only 5 days into Node/TypeScript/JavaScript).

function isPromiseLike<T>(x: any): x is PromiseLike<T> {
    return x != null && (<PromiseLike<T>>x).then != undefined;
}

function isPromiseX<T>(x: PromiseLike<T>): x is PromiseX<T> {
    return x != null && (<PromiseX<T>>x).type == PromiseX.symbolPromiseX;
}

enum State { Pending, Fulfilled, Rejected }

export class PromiseX<T = any> implements PromiseLike<T> {
    static symbolPromiseX: symbol = Symbol('PromiseX');
    readonly type: symbol = PromiseX.symbolPromiseX;

    private _state: State = State.Pending;

    private _result: T;

    private _reason: any;

    private _continuations: [(x: T) => void, (e: any) => void][] = [];

    constructor(executor?: (resolve: (value?: T | PromiseLike<T>) => void, reject: (reason?: any) => void) => void) {
        if (executor) {
            executor(result => this.setResult(result), error => this.setError(error));
        }
    }

    // if 'result' is 'undefined', it'll be passed along
    // Note that there is a potential race if the current state is pending, while
    // someone calls setResult on two different PromiseLike values - the first one
    // gets either fulfilled or rejected would set the current promise in a non-pending state,
    // and the second PromiseLike when eventually becomes non-pending would call either setResult or setError
    // but find the state becomes non-pending, thus would have no effect!
    public setResult(result?: T | PromiseLike<T>): void {
        if (this._state == State.Pending) {
            if (isPromiseLike(result)) {
                result.then(x => this.setResult(x), e => this.setError(e));
            } else {
                this._state = State.Fulfilled;
                this._result = result;
                for (let continuation of this._continuations) {
                    continuation[0](this._result);
                }
            }
        }
    }

    public setError(reason?: any): void {
        if (this._state == State.Pending) {
            this._state = State.Rejected;
            this._reason = reason;
            for (let continuation of this._continuations) {
                continuation[1](this._reason);
            }
        }
    }

    then<TResult1 = T, TResult2 = never>(onFulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | undefined | null, onRejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null): PromiseX<TResult1 | TResult2> {
        switch (this._state) {
            case State.Fulfilled: {
                let r : TResult1 | PromiseLike<TResult1>;
                if (onFulfilled) {
                    try {
                        r = onFulfilled(this._result);
                    } catch (e) {
                        let p = new PromiseX<never>();
                        p.setError(e);
                        return p;
                    }
                }
                if (isPromiseLike(r) && isPromiseX(r)) {
                    return r;
                }
                let p = new PromiseX<TResult1>();
                p.setResult(r);
                return p;
            }

            case State.Rejected: {
                if (onRejected) {
                    let r : TResult2 | PromiseLike<TResult2>;
                    try {
                        r = onRejected(this._reason);
                    } catch (e) {
                        let p = new PromiseX<never>();
                        p.setError(e);
                        return p;
                    }
                    if (isPromiseLike(r) && isPromiseX(r)) {
                        return r;
                    }
                    let p = new PromiseX<TResult2>();
                    p.setResult(r); // NB: the error has been handled, we pass along the handled result
                    return p;
                }
                let p = new PromiseX<TResult2>();
                p.setError(this._reason); // NB: the error is not handled, mark the returned promise to be the same error, so it can be passed along until handled
                return p;
            }

            case State.Pending: {
                let p = new PromiseX<TResult1 | TResult2>();
                this._continuations.push([
                    result => {
                        let r : TResult1 | PromiseLike<TResult1>;
                        if (onFulfilled) {
                            try {
                                r = onFulfilled(result);
                            } catch (e) {
                                p.setError(e);
                                return;
                            }
                        }
                        p.setResult(r);
                    },
                    reason => {
                        if (onRejected) {
                            let r : TResult2 | PromiseLike<TResult2>;
                            try {
                                r = onRejected(reason);
                            } catch (e) {
                                p.setError(e);
                                return;
                            }
                            p.setResult(r);
                        } else {
                            p.setError(reason); // NB: error not handled, pass the error along the chain
                        }
                    }
                ]);
                return p;
            }
        }
    }

    catch<TResult = never>(onRejected?: ((reason: any) => TResult | PromiseLike<TResult>) | undefined | null): PromiseX<T | TResult> {
        return this.then(undefined, onRejected);
    }
}
\$\endgroup\$

1 Answer 1

2
\$\begingroup\$

First, a few notes from your GitHub project

  1. The most important compiler option to turn on is strict. This protects you from many common errors and makes it much nicer when defining optional type parameters. After turning on strict mode, the | undefined part can be dropped from parameters. (Personally, I'd like to also drop null to enforce the use of a consistent. See null is bad)

  2. Consider using a proper testing framework, tests should ideally be quick and easy to read to see exactly what the code should be doing. The current testing code prints out a bunch of numbers without telling me for sure if everything is working or not. I will recommend Ava (potentially along with ava-ts) for the convenience of how it is designed to be used with promises. You could also just use this project to test your implementation.

Now on to your code :) In no particular order...

  1. Instead of defining a isPromiseX function, you might consider implementing the Symbol.hasInstance method and just using if (x instanceof PromiseX)

  2. With the default promise implementation in Chrome at least, if the passed executor function throws an error, the promise will be rejected. (Does not seem to be covered by the spec). It might be worth duplicating this behavior.

  3. setResult and setError should not be public. They are only used internally.

  4. I prefer early returns to nested code, in every case where you wrap a function's body with if (this._state == State.Pending), I would prefer to check if the state is not pending and return.

  5. The code is not fully spec compliant. See point 2.3.1 is not fulfilled.

  6. Instead of defining setResult and setError as prototype methods, since you pass them into functions it may be worth defining them with arrow functions as demonstrated here.

    private setResult = (result?: T | PromiseLike<T>): void => {
    

Good work on the implementation overall!

\$\endgroup\$
4
  • \$\begingroup\$ Thanks for your comments, Gerrit0! I’ll explore the Symbol system and it seems using instanceof is a more intuitive way of doing type guards. This implementation is meant for node.js and server side, so chrome compatibility is not my greatest concern, but your point is taken as a good reference. setResult/setError are public for convenience testing things, so I don’t have to hook them to another “producer”. I like early return too. Honestly, I didn’t really read through the resolution spec, the implementation was largely based on “common sense”, I’ll read through the spec though. \$\endgroup\$
    – Dejavu
    Commented Jan 16, 2018 at 6:27
  • \$\begingroup\$ Regarding the arrow function, I’m still not too clear regarding the difference between the way I defined them vs the arrow function approach: for the arrow function approach, can I pass “this.setResult” around? Because I knew when I defined it my way, the “this” context was lost, thats why I had to use a closure to wrap it. Thanks again for your suggestions! \$\endgroup\$
    – Dejavu
    Commented Jan 16, 2018 at 6:32
  • \$\begingroup\$ Oh, regarding the first point (having extra undefined and null union types), the reason is that I'm actually implementing the TypeScript defined PromiseLike<T> interface, whose then method is defined exactly like that. I was not too sure if I should just omit the undefined and null type, so I just included them in, and didn't bother too much. \$\endgroup\$
    – Dejavu
    Commented Jan 16, 2018 at 6:37
  • \$\begingroup\$ Arrow functions: see the playground \$\endgroup\$
    – Gerrit0
    Commented Jan 16, 2018 at 20:37

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.