Last active
July 9, 2026 08:00
-
-
Save benhatsor/583bd5a58a9bfb4c2f24d2622cb497ce to your computer and use it in GitHub Desktop.
Task (a-la Swift)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Task (a-la [Swift](https://developer.apple.com/documentation/swift/task)). | |
| * | |
| * @example | |
| * const task = new Task(async signal => { | |
| * await Task.sleep({ for: 1000, signal }) | |
| * return 'hello' | |
| * }) | |
| * task.abort() | |
| * console.log(await task.result) // { state: 'rejected', reason: [AbortError DOMException] } | |
| * try { | |
| * console.log(await task.value) | |
| * } | |
| * catch (error) { | |
| * if (Task.isAbortError(error)) console.log('aborted!') | |
| * else throw error | |
| * } | |
| */ | |
| export class Task<ReturnValue = void> extends AbortController { | |
| /** | |
| * The result from a throwing task, after it completes. | |
| * @returns | |
| * The task's result. | |
| * @remarks | |
| * If the task throws an error, this property propagates that error. Tasks that respond to being | |
| * aborted by throwing {@linkcode AbortError} have that error propagated here upon being aborted. | |
| */ | |
| value: Promise<ReturnValue> | |
| /** | |
| * The result or error from a throwing task, after it completes. | |
| * @returns | |
| * If the task succeeded, {@linkcode FulfilledResult} with the task's result as the associated | |
| * {@linkcode FulfilledResult.value value}; otherwise, {@linkcode RejectedResult} with the error | |
| * as the associated {@linkcode RejectedResult.reason reason}. | |
| */ | |
| result: Promise<Result<ReturnValue>> | |
| constructor( | |
| operationThrowing: (signal: AbortSignal) => Promise<ReturnValue> | |
| ) { | |
| super() | |
| this.value = new Promise<ReturnValue>((resolve, reject) => { | |
| operationThrowing(this.signal).then( | |
| value => resolve(value), | |
| error => reject(error) | |
| ) | |
| }) | |
| this.result = new Promise<Result<ReturnValue>>(resolve => { | |
| this.value.then( | |
| value => resolve({ state: 'fulfilled', value: value }), | |
| error => resolve({ state: 'rejected', reason: error }) | |
| ) | |
| }) | |
| } | |
| /** | |
| * @remarks | |
| * We disallow aborting the task with a custom reason. Instead, we always pass `undefined` to | |
| * {@linkcode AbortController.abort abort(reason?:)}, ensuring the abort reason always defaults | |
| * to an {@linkcode AbortError} `DOMException` we can later catch with | |
| * {@linkcode isAbortError isAbortError(e:)}. | |
| * @see | |
| * [DOM Spec](https://dom.spec.whatwg.org/#ref-for-dom-abortcontroller-abort%E2%91%A3) | |
| */ | |
| override abort = () => super.abort() | |
| /** @throws Abort reason (if passed {@linkcode signal} aborts). */ | |
| static async sleep({ for: duration, signal: passedSignal }: { | |
| for: number, | |
| signal: AbortSignal | |
| }) { | |
| // Throw immediately if passed signal already aborted | |
| passedSignal.throwIfAborted() | |
| const abortablePromise = new AbortablePromise() | |
| // If the timeout fires, resolve our promise | |
| const timeoutID = setTimeout(abortablePromise.abort, duration) | |
| // If passed signal aborts, clear the timeout and resolve our promise | |
| passedSignal.addEventListener('abort', () => { | |
| clearTimeout(timeoutID) | |
| abortablePromise.abort() | |
| }, { signal: abortablePromise.signal }) // Remove this listener when our promise resolves | |
| // Wait for either the timeout to resolve | |
| // or the passed signal to abort | |
| await abortablePromise.promise | |
| // Throw if passed signal was aborted | |
| passedSignal.throwIfAborted() | |
| } | |
| /** https://webidl.spec.whatwg.org/#aborterror */ | |
| static isAbortError(e: unknown): e is AbortError { | |
| return ( | |
| e instanceof DOMException && | |
| e.name === 'AbortError' | |
| ) | |
| } | |
| } | |
| /** https://webidl.spec.whatwg.org/#aborterror */ | |
| export type AbortError = DOMException & { name: 'AbortError' } | |
| export type Result<ReturnValue> = | |
| | { state: 'fulfilled', value: ReturnValue } | |
| | { state: 'rejected', reason: unknown } | |
| /** | |
| * Abortable Promise | |
| * @remarks | |
| * Call {@linkcode abort abort(reason?:)} to abort the signal and resolve the promise. | |
| */ | |
| class AbortablePromise extends AbortController { | |
| promise: Promise<typeof this.signal.reason> | |
| constructor() { | |
| super() | |
| const { promise, resolve } = Promise.withResolvers<typeof this.signal.reason>() | |
| this.promise = promise | |
| this.signal.addEventListener( | |
| /* type: */ 'abort', | |
| /* listener: */ () => resolve(this.signal.reason), | |
| /* options: */ { once: true } // Remove listener after invocation | |
| ) | |
| } | |
| // Bind abort method to instance to allow destructuring | |
| // (see: https://stackoverflow.com/a/10743608) | |
| override abort = super.abort.bind(this) | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment