class TaskClass
extends Promise<T>
Private

Task<T, E> — a lazy, retryable superset of Promise<T> for error handling that respects JavaScript's rules from the ground up. E is the declared failure type: the reason a caller expects when it fails, and what TaskClass#result surfaces as the Err. It is advisory (JS rejections are untyped, so await still throws unknown) but self-documenting — a Task<Config, ConfigFailure> reads like a Result<Config, ConfigFailure> signature did, and .result() settles to the bare Result<T, E> union — the value itself, or the typed Failure — which one Failure.is() check discriminates.

A Task is a real Promise (instanceof Promise holds, and the Promises/A+ suite passes — see test/task/promises-aplus.ts) built from work — a Recipe () => value, an Executor (resolve, reject, signal) => …, or a promise already running — that starts only when the Task is first awaited (or .then-ed, or TaskClass#perform-ed). A failure is a real rejection whose reason is a Failure. Those two choices are what make it work with the language:

  • await task returns the value or throws — the JS standard, so .then/.map/Promise.all all see the value, never a wrapper. (Making await yield { ok, value, error } would force every native method to see the wrapper too — the neverthrow trade-off, rejected. The wrapper shape lives behind one method, TaskClass#result.)
  • Promise.all/race/any fail-fast; try/catch handles it; instanceof Promise holds.
  • Because it is lazy, a relationship accessor can fire its RPC only on await; because every Task keeps its work and its derivation lineage, TaskClass#retry/ TaskClass#restart spawn fresh executions of the whole chain — including a combinator's members (the ember-concurrency model: a Promise instance settles once, but the Task re-runs). retry takes a delay, or a function of the attempt for backoff, and can bound each attempt; every attempt it abandons has its AbortSignal fired so subscriptions can be released.

The two-tier rule threads through every consuming method: a declared failure (a Failure) is an outcome the caller planned for, a bug (any other rejection) is not. result, match, unwrapOr and context act only on declared failures and let bugs keep flying to the one boundary that turns them into a crash report; mapErr (the adapter edge, where foreign errors get classified into Failures) and recover (the crash boundary itself) are the two deliberate catch-alls. mapErr takes a defined factory directly — mapErr(Unreadable, { path }) — chaining the original under cause, or a (cause) => Failure function when the payload has to be read out of the error being classified.

Construction takes work in whichever shape it already has — a Recipe (() => value), an Executor ((resolve, reject, signal) => …, the new Promise shape made lazy), or a promise that is already running — with or without new, since the exported value is call-or-construct like Boolean/Date. The executor's parameters are ordered exactly as the platform's, so an existing new Promise body can move into a Task unchanged, with the AbortSignal appended where the platform has nothing:

import { define, type Of } from '../result/failure.ts';
const GitScanFailed = define('GitScanFailed', (d: { root: string }) => `scan failed: ${d.root}`);
type GitScanFailure = Of<typeof GitScanFailed>;
type ChangeScan = { scope: 'everything' } | { scope: 'paths'; paths: Set<string> };
const runGit = async (root: string): Promise<string> => `M ${root}/lib/a.ts`;
const classify = (error: unknown): GitScanFailure => GitScanFailed({ root: '.' }, { cause: error });
const parse = (out: string): ChangeScan => ({ scope: 'paths', paths: new Set(out.split('\n')) });

function scanChanges(root: string): Task<ChangeScan, GitScanFailure> {
  return Task(() => runGit(root)).mapErr(classify).map(parse);
}

Constructors

TaskClass(source: PromiseLike<T> | Recipe<T> | Executor<T> | null | undefined)

Takes any of the three shapes work arrives in — all lazy, all identical with or without new:

  • null / undefined, which settle as-is having run nothing. This is what makes the cleanup idiom Task(handle?.close()).ignore('…') expressible: when the handle is already gone there is no promise to adopt and nothing to do, and that is not an error. Both are accepted because an absent handle is spelled either way across a codebase.
  • recipe, declaring no parameters — () => value, async () => value. Whatever it returns (value or promise) settles the Task. The common case.
  • executor, declaring at least one — (resolve, reject, signal) => …, the new Promise shape, so callback/event APIs settle a Task imperatively. Unlike new Promise it runs lazily (on first await/perform), gets an AbortSignal third for TaskClass#shutdown, and re-runs with fresh resolvers on every TaskClass#restart. A returned promise settles the Task too, so (_, __, signal) => fetch(url, { signal }) needs no resolver call at all.
  • promise, already running — the Task then defers only observation.

The two function shapes are told apart by declared arity (fn.length), which is why an executor must name at least resolve. (...args) => … and defaulted first parameters both declare zero, so they read as recipes — spell the parameters out to get an executor.

Type Parameters

E = AnyFailure

Static Properties

readonly
[Symbol.species]: PromiseConstructor

.then derives plain Promises, not Tasks — a derived promise has no recipe, and its constructor would be called with an executor, not a recipe. The chaining methods build real (lazy) Tasks explicitly instead.

Static Methods

all<T extends readonly unknown[] | []>(values: T): TaskClass<
-readonly [P in keyof T]: Awaited<T[P]>,
DeclaredFailureOf<T[number]>
>

Lazy Promise.all: on await, everything starts together, resolves positionally, and fail-fast applies — before the await, nothing has begun.

The combined Task keeps the members' declared failure — Task.all of two Task<T, LoadFailure> is a Task<T[], LoadFailure>, so .result() on it still discriminates the union you declared rather than a widened Failure.

const both = Task.all([Task(() => 'a'), Task(() => 'b')]); // nothing has started yet
await both; // ['a', 'b'] — both started HERE
all<T, E>(values: Iterable<TaskClass<T, E>>): TaskClass<Awaited<T>[], E>
all<T>(values: Iterable<T | PromiseLike<T>>): TaskClass<Awaited<T>[]>
allSettled<T extends readonly unknown[] | []>(values: T): TaskClass<
-readonly [P in keyof T]: PromiseSettledResult<Awaited<T[P]>>
>

Lazy Promise.allSettled, keeping the spec's { status, … } shape — contrast TaskClass.results, which yields typed Results instead.

const settled = await Task.allSettled([Task(() => 1), Task.reject(new Error('x'))]);
settled.map((s) => s.status); // ['fulfilled', 'rejected']
allSettled<T>(values: Iterable<T | PromiseLike<T>>): TaskClass<PromiseSettledResult<Awaited<T>>[]>
any<T extends readonly unknown[] | []>(values: T): TaskClass<Awaited<T[number]>>

Lazy Promise.any: the first SUCCESS wins; failures only lose.

await Task.any([Task.reject(new Error('x')), Task(() => 'ok')]); // 'ok'
any<T>(values: Iterable<T | PromiseLike<T>>): TaskClass<Awaited<T>>
async<T, E = AnyFailure>(source: Recipe<T> | Executor<T>): TaskClass<T, E>

Elixir's Task.async/1: build the task AND start it now — the async half of the async/await pair, for running work concurrently with the code that follows.

const a = Task.async(() => 2);
const b = Task.async(() => 3); // both running before either is awaited
(await a.await()) + (await b.await()); // 5
awaitMany<T, E>(
tasks: readonly TaskClass<T, E>[],
timeoutMs?: number
): Promise<T[]>

Elixir's Task.await_many/2: awaits every task under ONE shared deadline, positionally.

await Task.awaitMany([Task(() => 1), Task(() => 2)], 1000); // [1, 2]
completed<T>(value: T): TaskClass<Awaited<T>, never>

Elixir's Task.completed/1: an already-settled task — for handing a precomputed value to an API that expects a Task, with no recipe left to run.

await Task.completed(42); // 42
await Task.completed(42).yield(0); // 42 — settled before any window
context<T, E>(
task: TaskClass<T, E>,
message: string
): TaskClass<T, E>

Data-first twin of TaskClass#context — context for declared failures only.

await Task.context(Task(() => 'v'), 'must load'); // 'v' — context only decorates failures
ensure<T, E, F extends AnyFailure>(
task: TaskClass<T, E>,
predicate: (value: T) => boolean,
toFailure: (value: T) => F
): TaskClass<T, E | F>

Data-first twin of TaskClass#ensure — the invariant, receiver-first.

import { define } from '../result/failure.ts';
const Empty = define('Empty', 'no rows');

const rows = Task(() => [1, 2]);
await Task.ensure(rows, (r) => r.length > 0, () => Empty()); // [1, 2]
fail<F>(reason: F): TaskClass<never, F>

A Task that fails with reason (a rejection — so await throws it), typed: the declared E is exactly reason's type.

import { define } from '../result/failure.ts';
const Denied = define('Denied', (d: { user: string }) => `denied: ${d.user}`);

const denied = Task.fail(Denied({ user: 'root' })); // Task<never, Failure<'Denied', …>>
(await denied.result()).code; // 'Denied' — the failure arrives bare, typed end to end
ignore<T>(
source: PromiseLike<T> | (() => T | PromiseLike<T>),
reason: string
): TaskClass<T | undefined, never>

One-shot static spelling of TaskClass#ignore for a foreign promise or recipe — the fire-and-forget cleanup idiom in a single call. (The one twin whose first argument is a raw PromiseLike or recipe rather than a Task: ignore is a terminal verb usable without ever holding a Task.)

import { unlink } from 'node:fs/promises';

Task.ignore(unlink('/tmp/qunitx-daemon.sock'), 'daemon socket unlink');
map<T, E, U>(
task: TaskClass<T, E>,
fn: (value: T) => U | PromiseLike<U>
): TaskClass<U, E>

Data-first twin of TaskClass#map.

const upper = Task.map(Task(() => 'fetched'), (s) => s.toUpperCase());
mapErr<T, E, F>(
task: TaskClass<T, E>,
fn: (error: unknown) => F
): TaskClass<T, F>

Data-first twin of TaskClass#mapErr — the adapter edge, receiver-first.

import { define } from '../result/failure.ts';
const Classified = define('Classified', (d: { op: string }) => `failed: ${d.op}`);

const t = Task.mapErr(Task(() => 'ok'), (cause) => Classified({ op: 'io' }, { cause }));
await t; // 'ok' — success passes through untouched
match<T, E, A, B>(
task: TaskClass<T, E>,
handlers: { ok: (value: T) => A | PromiseLike<A>; err: (error: E) => B | PromiseLike<B>; }
): TaskClass<A | B, never>

Data-first twin of TaskClass#match — both declared branches, bugs keep flying.

await Task.match(Task(() => 2), { ok: (n) => n * 21, err: () => 0 }); // 42
orElse<T, E, F>(
task: TaskClass<T, E>,
fn: (error: E) => TaskClass<T, F> | PromiseLike<T>
): TaskClass<T, F>

Data-first twin of TaskClass#orElse — a declared failure's fallible second chance.

import { define } from '../result/failure.ts';
const Missed = define('Missed', 'cache miss');

const cached = Task<string>(() => {
  throw Missed();
});
await Task.orElse(cached, () => Task(() => 'from origin')); // 'from origin'
perform<T, E>(task: TaskClass<T, E>): TaskClass<T, E>

Data-first twin of TaskClass#perform — start now, hand the same task back.

const started = Task.perform(Task(() => 'now')); // running already
await started; // 'now'
race<T extends readonly unknown[] | []>(values: T): TaskClass<Awaited<T[number]>, DeclaredFailureOf<T[number]>>

Lazy Promise.race: the first settlement — success or failure — wins.

await Task.race([Task(() => 'fast'), Task<string>(() => new Promise<never>(() => {}))]);
// 'fast'
race<T, E>(values: Iterable<TaskClass<T, E>>): TaskClass<Awaited<T>, E>
race<T>(values: Iterable<T | PromiseLike<T>>): TaskClass<Awaited<T>>
recover<T, E, U = T>(
task: TaskClass<T, E>,
fn: (error: unknown) => U | PromiseLike<U>
): TaskClass<T | U, never>

Data-first twin of TaskClass#recover — the crash boundary, receiver-first.

await Task.recover(Task.reject<string>(new Error('boom')), () => 'safe'); // 'safe'
reject<T = never>(reason?: unknown): TaskClass<T>

A rejected Task, spec-shaped (reason erased to unknown). Prefer TaskClass.fail, which keeps the reason's type as the Task's declared E.

import assert from 'node:assert';

await assert.rejects(Task.reject(new Error('boom')), /boom/);

A resolved Task. Overridden because the inherited Promise.resolve builds via new this(executor), which our lazy constructor cannot satisfy — it stores the work rather than running it, so NewPromiseCapability never captures its resolvers. Same for every other inherited static (try above, reject/withResolvers/the combinators below).

A Task passes straight through, as Promise.resolve passes a same-constructor promise through: wrapping one would hand back something whose restart re-awaits a settled inner Task instead of re-running its work, which is the lineage a Task exists to keep.

await Task.resolve(42); // 42 — a settled value lifted into Task-land
resolve<T>(value: T | PromiseLike<T>): TaskClass<Awaited<T>>
restart<T, E>(task: TaskClass<T, E>): TaskClass<T, E>

Data-first twin of TaskClass#restart — a fresh execution of the whole chain.

let runs = 0;
const t = Task(() => ++runs);
await t; // 1
await Task.restart(t); // 2 — a fresh execution
result<T, E>(task: TaskClass<T, E> | PromiseLike<T>): TaskClass<Result<T, E>, never>

Data-first twin of TaskClass#result — the bridge to the bare Result union.

Takes a foreign promise as well as a Task, matching TaskClass.results and the constructor: this twin is most useful over a value someone handed you, and being handed a promise is the ordinary case. A promise is lifted first, so its rejection lands on the failure channel exactly as a Task's would.

const value = await Task.result(Task(() => 21 * 2)); // 42 — or the declared Failure, bare
await Task.result(Promise.resolve(7)); // 7 — a foreign promise needs no wrapping first
results<T, E = AnyFailure>(tasks: Iterable<TaskClass<T, E> | PromiseLike<T>>): TaskClass<Result<T, E>[], never>

Awaits every task and returns their outcomes positionally — index-preserving, so a batch knows which input failed, and no success is discarded (unlike Promise.all's fail-fast). The errors are the declared E; a bug in any task rejects the whole call, matching TaskClass#result's two-tier rule. (Named results, not allSettled, which is the inherited static with the spec's { status, … } shape.)

import { partition } from '../result/result.ts';
import type { Any as LoadFailure } from '../result/failure.ts';
const paths = ['a.json', 'b.json'];
const load = (path: string) => Task<object, LoadFailure>(() => ({ path }));

const outcomes = await Task.results(paths.map(load)); // (object | LoadFailure)[] — bare unions
const { values, errors } = partition(outcomes); // nothing lost, everything typed
retry<T, E>(
task: TaskClass<T, E>,
times?: number,
): TaskClass<T, E>

Data-first twin of TaskClass#retry — fresh restarts until success.

let tries = 0;
const flaky = Task(() => {
  if (++tries < 2) throw new Error('again');
  return tries;
});
await Task.retry(flaky); // 2
await Task.retry(flaky, 3, { delayMs: 1 }); // the instance method's options, unchanged
try<T, A extends unknown[]>(
fn: (...args: A) => T | PromiseLike<T>,
...args: A
): TaskClass<Awaited<T>>

The call boundary with arguments — Promise.try's shape, made lazy: fn(...args) runs on first await, and whatever it throws (sync or async) becomes the rejection. The closure the caller would otherwise write by hand (Task(() => fn(a, b))) is built here instead.

const runGit = async (args: string[], cwd: string) => `ran git ${args[0]} in ${cwd}`;

const scan = Task.try(runGit, ['status', '--porcelain'], '.'); // lazy, args captured
await scan.retry(2); // three fresh runGit executions at most
unwrapOr<T, E, U>(
task: TaskClass<T, E>,
fallback: U
): TaskClass<T | U, never>

Data-first twin of TaskClass#unwrapOr — fallback for declared failures only.

import { define } from '../result/failure.ts';
const Missing = define('Missing', 'missing');

await Task.unwrapOr(Task.fail(Missing()), 'fallback'); // 'fallback'
withResolvers<T>(): { promise: TaskClass<T>; resolve: (value: T | PromiseLike<T>) => void; reject: (reason?: unknown) => void; }

Promise.withResolvers, returning a Task settled from outside. Lazy like every method but ignore: the Task only observes the external settlement once something awaits it.

const socket = { once(_event: string, handler: (frame: string) => void) { handler('hi'); } };

const { promise, resolve } = Task.withResolvers<string>();
socket.once('frame', resolve);
await promise; // fine even if the frame landed before this line
yieldMany<T, E>(
tasks: readonly TaskClass<T, E>[],
timeoutMs?: number
): Promise<(Result<T, E> | null)[]>

Elixir's Task.yield_many/2: polls every task under one shared window — each slot is the task's Result if it settled in time, null if still running. Nothing is consumed.

const fast = Task(() => 1);
const never = Task<number>(() => new Promise<never>(() => {}));
await Task.yieldMany([fast, never], 20); // [1, null] — bare Result | null per slot

Methods

await(timeoutMs?: number): Promise<T>

Awaits the task with a deadline — Elixir's Task.await/2 (default 5000ms). Starts the run if needed; rejects with a declared Failure('AwaitTimeout') when the deadline fires first. The deadline does NOT cancel the work (TaskClass#shutdown does) — a later await task can still join the run, exactly like Elixir's yield-after-timeout.

await Task(() => 42).await(1000); // 42
const slow = Task(() => new Promise<never>(() => {}));
const failed = await slow.await(10).catch((e) => e);
(failed as { code: string }).code; // 'AwaitTimeout'
context(message: string): TaskClass<T, E>

Adds context to a declared failure — anyhow's .context(). Named for what it does, and deliberately NOT expect: Result.expect is Rust's, which PROMOTES a failure to a bug, and one word meaning both "keep this handled" and "crash on this" is the confusion worth spending a rename to avoid. A Failure rethrows as a new Failure with the same code and data (so E, and every switch on code, still hold), message as the context line, and the original chained under cause. A bug passes through untouched: promoting it into the declared tier would hide it from the boundary.

const loadUser = (id: number) => Task(() => ({ name: 'u' + id }));

await loadUser(7).context('route /users/7 needs its user');
// and when loadUser rejects with Failure(NotFound), the await throws:
// Failure(NotFound): route /users/7 needs its user
//   caused by: Failure(NotFound): no user 7
ensure<F extends AnyFailure>(
predicate: (value: T) => boolean,
toFailure: (value: T) => F
): TaskClass<T, E | F>

Declares an invariant on the success value: the value passes through when predicate holds, otherwise the Task rejects with the declared failure built from the value — which is what makes it the one-line spelling of the HTTP envelope check:

import { define, is } from '../result/failure.ts';
const PageFailed = define('PageFailed', (d: { status: number }) => `page failed: HTTP ${d.status}`);
const respond = (status: number) => Task(() => new Response(null, { status }));

const checked = await respond(404).ensure((res) => res.ok, (res) => PageFailed({ status: res.status })).result();
is(checked) && checked.code; // 'PageFailed' — declared, typed, one line
await respond(200).ensure((res) => res.ok, (res) => PageFailed({ status: res.status })).map((r) => r.status); // 200
finally(onFinally?: (() => void) | null): TaskClass<T, E>

Promise.prototype.finally, returning a Task so cleanup does not drop the chain into plain-Promise land. Every spec behaviour is kept: onFinally takes no arguments, its return value is discarded, a thenable it returns is awaited, and anything it throws replaces the outcome.

Prefer try/finally to this. It exists so that calling it on a Task — which is a real Promise, so anyone may — behaves sensibly, not because it is the better spelling. A plain try/finally inside the recipe is better on every axis:

const acquire = async () => ({ read: async () => 'body', release: () => {} });

// preferred: lazy, retryable, and the cleanup runs once per attempt
Task(async () => {
  const handle = await acquire();
  try {
    return await handle.read();
  } finally {
    handle.release();
  }
});

The reason is that this method must be EAGER — finally is overwhelmingly written fire-and-forget (closeWithGrace(...).finally(() => process.exit(143))), and a lazy one nobody awaited would never release. Being eager, it hands back a running Task, so task.finally(cleanup).retry(3) leaves the first attempt's rejection unowned and reports an unhandled rejection. retry first, finally last — or try/finally, which cannot be held wrong this way.

let released = false;
const value = await Task(() => 'body').finally(() => {
  released = true; // runs whichever way the Task settles
});

value; // 'body' — the outcome passes through untouched
released; // true
ignore(reason: string): TaskClass<T | undefined, never>

Deliberate non-handling — the Task spelling of promise.catch(Failure.ignore(reason)). Swallows every rejection (bugs included: this is for cleanup whose failure genuinely has no consequence) and says so on stderr under QUNITX_DEBUG instead of vanishing.

Unlike every other method this one starts the task: "the outcome does not matter" is not a decision laziness can defer, and eager attachment is what keeps a fire-and-forget call site from ever holding an unobserved rejection.

import { unlink } from 'node:fs/promises';

Task(unlink('/tmp/qunitx-daemon.sock')).ignore('daemon socket unlink'); // fire and forget
await Task(unlink('/tmp/qunitx.lock')).ignore('daemon lock unlink'); // or join the cleanup
map<U>(fn: (value: T) => U | PromiseLike<U>): TaskClass<U, E>

Transforms the success value — .then(fn) that stays a Task: lazy, retryable, E kept. fn may return a value or a promise of one (they flatten), so this is andThen too.

const loadUser = (id: number) => Task(() => ({ name: 'u' + id }));

loadUser(1).map((u) => u.name).map((n) => n.toUpperCase()); // still lazy, still a Task
mapErr<Code extends string, Data>(
factory: FailureFactory<Code, Data>,
data: Data
): TaskClass<T, FailureOf<Code, Data>>

Transforms the failure reason — the adapter edge. This is the one transforming method that sees every rejection (error: unknown), because its job is to classify foreign errors — an execFile timeout, a driver throw — into the declared Failure taxonomy. Downstream of a mapErr, the two-tier methods can trust what they see.

import { define } from '../result/failure.ts';
const GitScanFailed = define('GitScanFailed', (d: { ref: string }) => `scan failed: ${d.ref}`);
const execFileAsync = async (cmd: string, args: string[]) => `${cmd} ${args[0]}`;
const ref = 'HEAD';

Task(() => execFileAsync('git', ['status'])) // foreign throw-land
  .mapErr((cause) => GitScanFailed({ ref }, { cause })); // classified HERE, once

A defined factory may be passed directly, with its payload as the second argument — the original is chained under cause for you. Factories carry their own code and is, so they are told from a mapper exactly rather than by arity:

import { define } from '../result/failure.ts';
const Unreadable = define('Unreadable', (d: { path: string }) => `cannot read ${d.path}`);
const Denied = define('Denied', 'permission denied');

Task(() => Promise.reject(new Error('EACCES')))
  .mapErr(Unreadable, { path: '/etc/app.json' }); // payload given, cause chained
Task(() => Promise.reject(new Error('EACCES'))).mapErr(Denied); // no payload to give

Reach for the function form whenever the payload is read from the cause — the git scan above takes its reason from the error's first line, which no shorthand can express.

mapErr<Code extends string>(factory: FailureFactory<Code, undefined>): TaskClass<T, FailureOf<Code, undefined>>
mapErr<F>(fn: (error: unknown) => F): TaskClass<T, F>
match<A, B>(handlers: { ok: (value: T) => A | PromiseLike<A>; err: (error: E) => B | PromiseLike<B>; }): TaskClass<A | B, never>

Handles both declared branches — err receives the typed E, so it is two-tier like TaskClass#result: a bug belongs to neither branch and keeps rejecting.

const deploy = () => Task(() => 'v2.1.0');
const statusFor = (_code: string) => 503;

const status = await deploy().match({ ok: () => 201, err: (e) => statusFor(e.code) });
orElse<F>(fn: (error: E) => TaskClass<T, F> | PromiseLike<T>): TaskClass<T, F>

The declared failure's second chance: on a declared E, run fn and adopt whatever it produces — including its failure. Rust's Result::or_else, and the fallible twin of TaskClass#unwrapOr.

Two-tier, like unwrapOr and unlike TaskClass#recover: a bug is not a planned outcome and does not get a fallback. "Try the replica when the primary says NotFound" is a plan; "try the replica when the primary threw a TypeError" is a way to ship the TypeError to production twice.

The distinction from recover is the return type, and it is the whole point: recover promises no declared failure remains (E becomes never), so its handler must not have one to give. orElse keeps the channel open, so the fallback is allowed to fail and the caller still has something to discriminate.

import { define, type Of } from '../result/failure.ts';
const NotFound = define('NotFound', (d: { id: number }) => `no user ${d.id}`);
type NotFoundFailure = Of<typeof NotFound>;

const fromPrimary = (id: number): Task<string, NotFoundFailure> =>
  Task(() => {
    throw NotFound({ id });
  });
const fromReplica = (id: number): Task<string, NotFoundFailure> => Task(() => `user ${id}`);

// still a Task<string, NotFoundFailure> — the replica is allowed to miss too
await fromPrimary(7).orElse(() => fromReplica(7)); // 'user 7'
perform(): this

Starts the run now without suspending the caller (ember-concurrency's verb), so work can overlap: task.perform() early, await task later joins the in-flight run. Idempotent — on a running or settled Task it is a no-op join. Returns this for chaining.

An unconsumed performed Task that fails becomes an unhandled rejection, exactly like any un-awaited promise — perform-and-forget still wants a .result() or a recover somewhere.

const scanChanges = (root: string, ref: string) => Task(() => new Set([root, ref]));
const buildFsTree = async (config: object): Promise<object> => config;

const scan = scanChanges('.', 'HEAD').perform(); // git starts NOW
const tree = await buildFsTree({}); // overlapped work
const changes = await scan; // join the in-flight run — no second git call
recover<U = T>(fn: (error: unknown) => U | PromiseLike<U>): TaskClass<T | U, never>

Recovers by producing a success value — the crash boundary, the Task spelling of the one .catch() at the top of a program. Sees every rejection, bugs included; everything downstream is settled, so E is never.

const route = (_req: Request) => Task(() => new Response('ok'));
const internalError = () => new Response(null, { status: 500 });
const log = (value: unknown): void => console.debug(value);
const req = new Request('https://example.com/users/7');

const reply = await route(req).recover((bug) => {
  log(bug);
  return internalError();
});

A brand-new execution: fresh recipe run for a root Task, and for a derived Task the lineage is walked — the source restarts and every derivation step is re-applied. So scan.map(parse).context(ctx).restart() re-runs the git call, the parse, and the context wrap; nothing is served from the old chain's memo.

const chain = Task(() => 'fetched').map((s) => s.toUpperCase());

await chain; // ran once, memoised
await chain.restart(); // fresh source execution, every derivation re-applied
await chain; // the original still serves its memo
result(): TaskClass<Result<T, E>, never>

Settles to the bare Result<T, E> union — the value itself, or the declared Failure as a value — so a caller branches with one Failure.is() check and no try/catch. The two-tier gate: only a declared Failure is reflected into the value world; a bug is re-thrown, so it lands at the program's one crash boundary instead of being silently returned.

Classifying a failure also reports it to the observation seam (Failure.observed — the qunitx.failure.observed channel plus Failure.onObserved), as do match/unwrapOr/ recover: a tracing adapter subscribed there annotates the active span with Failure.attributes(error), with zero tracing code at any call site.

Lazy like every method but ignore, and lineage-carrying: task.result().restart() re-runs the chain and reflects the fresh outcome.

import { isFailure, type Any as GitScanFailure } from '../result/failure.ts';
const scanTask = Task<Set<string>, GitScanFailure>(() => new Set(['lib/a.ts']));
const degradeToFullRun = (_failure: GitScanFailure) => new Set<string>();

const scan = await scanTask.result(); // Set<string> | GitScanFailure — bare
if (isFailure(scan)) degradeToFullRun(scan); // scan: GitScanFailure — typed
else scan.has('lib/a.ts'); // scan: Set<string>
retry(
times?: number,
): TaskClass<T, E>

Re-runs until success, spawning a fresh TaskClass#restart execution per attempt — the first attempt included, so a Task that already ran and failed retries cleanly. Gives up after times retries (initial + times executions) and rejects with the last reason. Failure-blind by design: transient bugs (a socket reset surfacing as a raw error before its mapErr) are exactly what call sites retry, so every rejection counts as an attempt.

An attempt that fails is then abandoned: its AbortSignal fires so an executor that subscribed to something can unsubscribe, rather than leaving a listener behind per attempt. The attempt has already settled, so this changes no outcome and its reason never reaches the caller — the failure they see is their own. Recipes take no signal and are untouched.

The second argument is the wait between attempts — a number, or a function of the attempt just finished for backoff — or the full { delayMs, timeoutMs } bag. timeoutMs bounds each attempt individually: the deadline abandons it (its signal fires, so cancellation-aware work stops) and it counts as a failure like any other. A pending wait is abortable, so shutdown() during one settles immediately rather than outliving the Task.

import { getChangedFilePathsInGitSince } from '../utils/get-changed-file-paths-in-git-since.ts';

// Defined, not invoked: the scan spawns real git subprocesses when awaited.
function resilientScan(root: string, ref: string) {
  return getChangedFilePathsInGitSince(root, ref).retry(); // survives index.lock contention
}

let attempts = 0;
const flakyUpload = Task(() => (++attempts < 3 ? Promise.reject(new Error('flaky')) : 'ok'));
await flakyUpload.retry(4); // 'ok' — succeeded on the 3rd of up to 5 fresh executions

attempts = 0;
await flakyUpload.retry(4, 1); // the same, waiting 1ms between attempts
attempts = 0;
await flakyUpload.retry(4, (attempt) => attempt); // backoff: 1ms, then 2ms
attempts = 0;
await flakyUpload.retry(4, { delayMs: 1, timeoutMs: 5_000 }); // and a per-attempt deadline
shutdown(timeoutMs?: number): Promise<Result<T, E> | null>

Aborts the run and reports what was there — Elixir's Task.shutdown/2. Fires the Executor's AbortSignal, so work that was handed it — a fetch(url, { signal }) — stops; JS cannot preempt work that ignores it, which finishes detached. Then yields for timeoutMs: the settled Result if one landed, else null after settling the task with a declared Failure('Shutdown') so every consumer resolves.

A Recipe declares no parameters and so never sees the signal — it cannot be interrupted, only abandoned. Take the executor shape for work that should stop.

const slow = Task<number>((resolve, _reject, signal) => {
  const timer = setTimeout(() => resolve(1), 60_000);
  signal.addEventListener('abort', () => clearTimeout(timer));
});
slow.perform();

await slow.shutdown(10); // null — nothing had landed, and the timer was cleared
then<TResult1 = T, TResult2 = never>(
onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null,
onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null
): Promise<TResult1 | TResult2>

Runs the recipe on first await/then. This is the single trigger point for the lazy work — catch and finally route through it too, since both call then per spec.

The signature mirrors Promise.prototype.then exactly (including reason: any, which the lib.d.ts declaration uses) so the subclass stays assignable everywhere a Promise is.

const task = Task(() => [{ name: 'ada' }]);

const names = task.then((users) => users.map((u) => u.name)); // plain Promise out
await names; // ['ada'] — to stay in Task-land (lazy, retryable, typed E), use .map instead
unwrapOr<U>(fallback: U): TaskClass<T | U, never>

Substitutes fallback for a declared failure. A bug still rejects — a fallback that absorbed a TypeError would be the silent-bug-hider the two-tier rule exists to prevent.

Takes a value, not a thunk: unwrapOr(() => x) falls back to the function itself. For a fallback computed from the failure, use match({ ok: (v) => v, err: fn }) — same two-tier gate, and err runs only when there is one.

const loadConfig = () => Task(() => ({ port: 8080 }));
const DEFAULTS = { port: 3000 };

const config = await loadConfig().unwrapOr(DEFAULTS);
yield(timeoutMs?: number): Promise<Result<T, E> | null>

Non-destructive poll — Elixir's Task.yield/2: settles to the task's Result if it finishes within the window, or null if it is still running. Never consumes: the memo persists, so a later yield, await, or result() still sees the outcome. A bug rejection still rethrows (the two-tier gate is result()'s).

const task = Task(() => 7);
await task.yield(50); // 7 — settled within the window, bare
const slow = Task(() => new Promise<never>(() => {}));
await slow.yield(10); // null — still running, NOT consumed

See

  • docs/error-handling.md