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 taskreturns the value or throws — the JS standard, so.then/.map/Promise.allall see the value, never a wrapper. (Makingawaityield{ 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/anyfail-fast;try/catchhandles it;instanceof Promiseholds.- 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).retrytakes a delay, or a function of the attempt for backoff, and can bound each attempt; every attempt it abandons has itsAbortSignalfired 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); }
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 idiomTask(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) => …, thenew Promiseshape, so callback/event APIs settle a Task imperatively. Unlikenew Promiseit runs lazily (on first await/perform), gets anAbortSignalthird 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.
[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.
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
allSettled<T extends readonly unknown[] | []>(values: T): TaskClass<>
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>>[]>
Lazy Promise.any: the first SUCCESS wins; failures only lose.
await Task.any([Task.reject(new Error('x')), Task(() => 'ok')]); // 'ok'
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
Elixir's Task.await_many/2: awaits every task under ONE shared deadline, positionally.
await Task.awaitMany([Task(() => 1), Task(() => 2)], 1000); // [1, 2]
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
Data-first twin of TaskClass#context — context for declared failures only.
await Task.context(Task(() => 'v'), 'must load'); // 'v' — context only decorates failures
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]
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
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');
Data-first twin of TaskClass#map.
const upper = Task.map(Task(() => 'fetched'), (s) => s.toUpperCase());
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
Data-first twin of TaskClass#match — both declared branches, bugs keep flying.
await Task.match(Task(() => 2), { ok: (n) => n * 21, err: () => 0 }); // 42
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'
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'
Data-first twin of TaskClass#recover — the crash boundary, receiver-first.
await Task.recover(Task.reject<string>(new Error('boom')), () => 'safe'); // 'safe'
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
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
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
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
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
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
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
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'
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
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
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
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
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>>
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) });
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
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
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,options?: RetryDelay | RetryOptions): 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
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
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);
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
docs/error-handling.md