bronson
bronson

Reputation: 6002

Possible to add a cancel method to Promise in Typescript?

I'd like to create a QueryPromise that is just a concrete promise with a cancel method. Here's how it would be used:

function runQuery(text: string): QueryPromise {
  return new QueryPromise((resolve,reject) => {nativeQuery(resolve)})
}

Here is my first attempt, hopefully pretty understandable:

interface CancellablePromise<T> extends Promise<T> {
  cancel: () => void
}

// create the promise my app will use
type QueryPromise = CancellablePromise<string|boolean>

But that's not sufficient.

After a few hours of trial and error I managed to get close, but this seems tedious and anything but DRY.

interface CancellablePromise<T> extends Promise<T> {
  cancel: () => void
}
// also need this interface so the variable can be declared
// plus need to redeclare methods to return the new type
interface CancellablePromiseConstructor extends PromiseConstructor {
  new <T>(executor: (resolve: (value?: T | PromiseLike<T>) => void, reject: (reason?: any) => void) => void): CancellablePromise<T>;
  cancel: () => void
}

type QueryPromise = CancellablePromise<string|boolean>    // for parameter types
var QueryPromise: CancellablePromiseConstructor = Promise // for the new operator

// some example code to exercise the promise
function runQuery(text: string): QueryPromise {
  return new QueryPromise((resolve,reject) => {nativeQuery(resolve)})
}

I feel like I've gone a long way down the wrong road... Does anyone know of a better way to do this?

Upvotes: 8

Views: 10077

Answers (5)

Javier
Javier

Reputation: 111

A class can be created that inherites from Promise and adds an OnCancel to the executor parameter of the Promise constructor:

export class CancellablePromise<T> extends Promise<T> {
    private onCancel: () => void

    constructor(executor: (resolve: (value?: T | PromiseLike<T>) => void, reject: (reason?: any) => void, onCancel: (cancelHandler: () => void) => void) => void) {
        let onCancel: () => void;
        super((rs, rj) => executor(rs, rj, (ch: () => void) => onCancel = ch));
        this.onCancel = onCancel;
    }

    public cancel() {
        if (this.onCancel) {
            this.onCancel();
        }
    }
}

Here is an example of usage:

public static search(query: string): CancellablePromise<Region[]> {
    return new CancellablePromise((resolve, reject, onCancel) => {
        const request = $.ajax({
            type: "GET",
            url: ROOT_URL + 'api/Regions',
            data: { q: query },
            success: (regions) => resolve(regions),
            error: (jqXHR) => reject(jqXHR.responseText),
        });
        onCancel(() => {
            if (request.state() == "pending") request.abort();
        });
    });
}

As you can see, this implementation can be constructed like any other promise. It can be used wherever a promise can and it includes a cancellation method.

Here is an example cancelling the promise:

// Cancel previous search (if any)
if (this.currentSearch) {
    this.currentSearch.cancel();
}

this.currentSearch = Regions.search(query);
this.currentSearch
    .then((regions) => {
        if (regions) {
            this.setState({ isSearching: false, regions: regions });
        } else {
            this.setState({ isSearching: false, regions: [] });
        }
    })
    .catch(() => this.setState({ isSearching: false, regions: [] }));

Upvotes: 2

Arsenius
Arsenius

Reputation: 5652

How about this implementation?

declare class CancelablePromise<T> extends Promise<T> {
  declare readonly cancel: () => void
}

interface Promise<T> {
  asCancelable(): CancelablePromise<T>
}


Promise.prototype.asCancelable = function () {
  let cancel: (reason: {cancelled: boolean}) => void
  const wrappedPromise = new Promise((resolve, reject) => {
    cancel = reject
    Promise.resolve(this)
      .then(resolve)
      .catch(reject)
  }) as any
  wrappedPromise.cancel = () => {
    cancel({cancelled: true})
  }
  return wrappedPromise as CancelablePromise<unknown>
}

Usage:

const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
const waiting = sleep(1000).asCancelable()
waiting.then(() => {
  console.info('this never get called')
})
waiting.cancel()

Upvotes: 0

Dmitry Astafyev
Dmitry Astafyev

Reputation: 81

Solution from @kayahr really smart and elegant. But I would not use because it's quite rude to break thread inside promise's executer.

Who knows, maybe there are some IO operations. Reading/writing operations. And such cancelation might be dangerous. I mean file could be not closed as well or something like it.

In my work, I also was needed a "cancelable" promise. I did a little bit other solution.

The idea is, it isn't truly canceled, this is a way to have one more option of results. Actually promise gives us 2 + 1 ways:

  1. then: all done, all ok
  2. catch: ohh, error
  3. finally: do it, just do it

But if by some reason we would like to stop operation, we don't have such option.

With this solution, it's possible.

export type TResolver<T> = (value?: T) => void;
export type TRejector = (error: Error) => void;
export type TFinally = () => void;
export type TCanceler<T> = (reason?: T) => void;

export class CancelablePromise<T, C> {

    private _resolvers: Array<TResolver<T>> = [];
    private _rejectors: TRejector[] = [];
    private _cancelers: Array<TCanceler<C>> = [];
    private _finishes: TFinally[] = [];
    private _canceled: boolean = false;
    private _resolved: boolean = false;
    private _rejected: boolean = false;
    private _finished: boolean = false;

    constructor(
        executor: (resolve: TResolver<T>, reject: TRejector, cancel: TCanceler<C>, self: CancelablePromise<T, C>) => void,
    ) {
        const self = this;
        // Create and execute native promise
        new Promise<T>((resolve: TResolver<T>, reject: TRejector) => {
            executor(resolve, reject, this._doCancel.bind(this), self);
        }).then((value: T) => {
            this._doResolve(value);
        }).catch((error: Error) => {
            this._doReject(error);
        });
    }

    public then(callback: TResolver<T>): CancelablePromise<T, C> {
        this._resolvers.push(callback);
        return this;
    }

    public catch(callback: TRejector): CancelablePromise<T, C> {
        this._rejectors.push(callback);
        return this;
    }

    public finally(callback: TFinally): CancelablePromise<T, C> {
        this._finishes.push(callback);
        return this;
    }

    public cancel(callback: TCanceler<C>): CancelablePromise<T, C> {
        this._cancelers.push(callback);
        return this;
    }

    public break(reason: C): CancelablePromise<T, C> {
        this._doCancel(reason);
        return this;
    }

    private _doResolve(value: T) {
        if (this._canceled) {
            return;
        }
        this._resolved = true;
        this._resolvers.forEach((resolver: TResolver<T>) => {
            resolver(value);
        });
        this._doFinally();
    }

    private _doReject(error: Error) {
        if (this._canceled) {
            return;
        }
        this._rejected = true;
        this._rejectors.forEach((rejector: TRejector) => {
            rejector(error);
        });
        this._doFinally();
    }

    private _doFinally() {
        if (this._finished) {
            return;
        }
        this._finished = true;
        this._finishes.forEach((handler: TFinally) => {
            handler();
        });
    }

    private _doCancel(reason?: C) {
        if (this._resolved || this._rejected || this._canceled) {
            // Doesn't make sence to cancel, because it was resolved or rejected or canceled already
            return this;
        }
        this._canceled = true;
        this._cancelers.forEach((cancler: TCanceler<C>) => {
            cancler(reason);
        });
        this._doFinally();
    }

}

Let's see how it works.

const a: CancelablePromise<void, void> = new CancelablePromise<void, void>((resolve, reject, cancel) => {
    // Do some long operation
    setTimeout(() => {
        resolve();
    }, 1000);
}).then(() => {
    console.log('resolved');
}).finally(() => {
    console.log('finally done');
});

Pretty much like normal promise. In output:

resolved
finally done

Let's try reject:

const b: CancelablePromise<void, void> = new CancelablePromise<void, void>((resolve, reject, cancel) => {
    // Do some long operation
    setTimeout(() => {
        console.log('timer #1');
        resolve();
    }, 1000);
    setTimeout(() => {
        console.log('timer #2');
        reject(new Error('Because I can!'));
    }, 500);
}).then(() => {
    console.log('resolved');
}).catch((error: Error) => {
    console.log('error');
}).finally(() => {
    console.log('finally done');
});

Output:

timer #2
error
finally done
timer #1

Now we will cancel it

const c: CancelablePromise<void, void> = new CancelablePromise<void, void>((resolve, reject, cancel) => {
    // Do some long operation
    setTimeout(() => {
        console.log('timer #1');
        resolve();
    }, 1000);
    setTimeout(() => {
        console.log('timer #2');
        reject(new Error('Because I can!'));
    }, 500);
    setTimeout(() => {
        console.log('timer #3');
        cancel();
    }, 250);
}).then(() => {
    console.log('resolved');
}).cancel(() => {
    console.log('canceled');
}).catch((error: Error) => {
    console.log('error');
});

Output

timer #3
timer #2
canceled
timer #1

Good we don't have resolve or reject fired, but (!) our executer still worked after promise was canceled. It's not good and here is a way how to fix it and cancel it in true way.

const d: CancelablePromise<void, void> = new CancelablePromise<void, void>((resolve, reject, cancel, self) => {
    // Listen cancel inside executer to cancel all correctly
    self.cancel(() => {
        clearTimeout(t1);
        clearTimeout(t2);
    });
    // Do some long operation
    const t1: any = setTimeout(() => {
        console.log('timer #1');
        resolve();
    }, 1000);
    const t2: any = setTimeout(() => {
        console.log('timer #2');
        reject(new Error('Because I can!'));
    }, 500);
    setTimeout(() => {
        console.log('timer #3');
        cancel();
    }, 250);
}).then(() => {
    console.log('resolved');
}).cancel(() => {
    console.log('canceled');
}).catch((error: Error) => {
    console.log('error');
});

Output

timer #3
canceled

So, timers #1 and #2 was stopped - promise was canceled carefully.

Sure we can cancel it outside also.

const d: CancelablePromise<void, void> = new CancelablePromise<void, void>((resolve, reject, cancel, self) => {
    // Listen cancel inside executer to cancel all correctly
    self.cancel(() => {
        clearTimeout(t1);
        clearTimeout(t2);
    });
    // Do some long operation
    const t1: any = setTimeout(() => {
        console.log('timer #1');
        resolve();
    }, 1000);
    const t2: any = setTimeout(() => {
        console.log('timer #2');
        reject(new Error('Because I can!'));
    }, 500);
}).then(() => {
    console.log('resolved');
}).cancel(() => {
    console.log('canceled');
}).catch((error: Error) => {
    console.log('error');
});

setTimeout(() => {
    console.log('timer #3 (outside)');
    d.break();
}, 250);

Result will be same:

timer #3 (outside)
canceled

Hope it will be useful.

Upvotes: 0

Paleo
Paleo

Reputation: 23692

TypeScript interfaces and types describe contracts. Yours are fine:

interface CancellablePromise<T> extends Promise<T> {
  cancel: () => void
}

type QueryPromise = CancellablePromise<string | boolean>

You can then implement a contract as you want. Here is an example:

function runQuery(text: string): QueryPromise {
  let rejectCb: (err: Error) => void
  let p: Partial<QueryPromise> = new Promise<string | boolean>((resolve, reject) => {
    rejectCb = reject
    /* ... Here the implementation of the query ... */
  });
  p.cancel = () => {
    /* ... Here the implementation of the aborting ... */
    rejectCb(new Error("Canceled"))
  }
  return p as QueryPromise
}

Notices:

  • The implementation of cancel should reject the promise;
  • I use Partial<QueryPromise> in order to add the member cancel afterward.

Upvotes: 7

kayahr
kayahr

Reputation: 22020

I implemented something like this a few months ago. My solution does not exactly match your desired API but maybe it works for you or you can adapt it to your needs:

The Canceled error

First of all you should be aware that it is a bad idea to have a promise which is not resolved and not rejected at all. A third state (canceled) will break existing code and an application which is not changed to explicitly look for cancelation will hang forever. That's why I reject a promise with a special Canceled error when the promise is canceled. A Cancelation-aware reject handler can distinguish a failure from cancelation by looking at the error type (With instanceof). A cancelation-unaware reject handler will treat cancelation like an error.

Here is a simple implementation of such a custom error class:

class Canceled extends Error {
    constructor(reason: string = "") {
        super(reason);
        Object.setPrototypeOf(this, Canceled.prototype);
    }
}

The setPrototypeOf call is needed so instanceof can be used to detect an instance of this error type.

The Cancelable interface

Next you need an interface for the cancelable promise. Mine is pretty much like yours:

interface Cancelable<T> extends Promise<T> {
    cancel(reason?: string): Cancelable<T>;
}

The cancelable function

Creating an implementation class of the Cancelable interface is a bad idea. It is hard to inherit from the built-in Promise type. So I decided to use a standard Promise object and simply add the cancel method to the Promise instance instead of creating a new type. For this I use this function:

function cancelable<T>(promise: Promise<T>, onCancel?: (canceled: Canceled) => void): Cancelable<T> {
    let cancel: ((reason: string) => Cancelable<T>) | null = null;
    let cancelable: Cancelable<T>;
    cancelable = <Cancelable<T>>new Promise((resolve, reject) => {
        cancel = (reason: string = "") => {
            try {
                if (onCancel) {
                    onCancel(new Canceled(reason));
                }
            } catch (e) {
                reject(e);
            }
            return cancelable;
        };
        promise.then(resolve, reject);
    });
    if (cancel) {
        cancelable.cancel = cancel;
    }
    return cancelable;
}

So this function takes a normal Promise as first parameter and an onCancel callback as second parameter which is called when the cancel() method is called on the returned Cancelable.

Creating a cancelable promise

To actually create a cancelable promise you have to wrap a standard promise and your cancel handler with the cancelable function. Here is an example sleep function which is resolved after the given time (Using setTimeout) and which can be canceled (which clears the timeout with clearTimeout):

function sleep(ms: number): Cancelable<void> {
    let timer: any;
    return cancelable(new Promise(resolve => {
        timer = setTimeout(resolve, ms);
    }), canceled => {
        clearTimeout(timer);
        throw canceled;
    });
}

Notice that the onCancel callback receives a canceled argument which is an instance of the Canceled error type. The onCancel callback must throw this error when the promise was canceled successfully. If the promise can't be canceled for some reason then simply don't throw the error and the promise will continue its usual work.

How to cancel the promise

And finally here is an example how to cancel a cancelable promise and how to react on it:

const waiting = sleep(1000);
waiting.then(() => {
    console.log("Wait successful");
}, error => {
    if (error instanceof Canceled) {
        console.log("Wait canceled. Reason: " + error.message);
    } else {
        console.error("Wait failed:", error);
    }
});

waiting.cancel("Nah, stop waiting");

I hope this works for you or at least gives you some ideas how to make your own implementation.

Upvotes: 1

Related Questions