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:
- then: all done, all ok
- catch: ohh, error
- 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.