Skip to content

Instantly share code, notes, and snippets.

@benhatsor
Last active July 9, 2026 08:00
Show Gist options
  • Select an option

  • Save benhatsor/583bd5a58a9bfb4c2f24d2622cb497ce to your computer and use it in GitHub Desktop.

Select an option

Save benhatsor/583bd5a58a9bfb4c2f24d2622cb497ce to your computer and use it in GitHub Desktop.
Task (a-la Swift)
/**
* 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