Task — lazy async
Promises are JavaScript’s built-in tool for async work, but they have two quirks that make them
hard to compose. They start immediately when created, so you can’t build a pipeline and hand it
around before any work begins. And they can reject, leaking failure as an untyped exception that
callers have no way to anticipate from the return type alone. Task<A> solves both.
The problems with Promises
Section titled “The problems with Promises”Promises are eager. A Promise starts the moment it’s created:
You can’t build a pipeline of async steps and pass it around before any work begins — by the time you have the Promise in hand, the work is already underway.
Promises can reject. Failure leaks out as an untyped exception rather than as a typed value.
This forces try/catch at every call site and makes it impossible to tell from a function’s return
type whether it can fail.
The Task approach
Section titled “The Task approach”A Task<A> is a zero-argument function that returns a Deferred<A>:
Deferred<A> is a minimal async value: it supports await but has no .catch(), .finally(), or
chainable .then(). This reinforces the infallibility guarantee at the type level — there is simply
no way to register a rejection handler on a Deferred.
This addresses both problems with Promises. The function wrapper makes it lazy — nothing runs until
you call it. And by treating Tasks as always-succeeding computations, failure is pushed into the
type: TaskResult<E, A> is Task<Result<E, A>>, so it’s impossible to overlook.
The pipeline is built first, then executed once by calling it. You can pass it around, compose it further, or call it multiple times.
Creating Tasks
Section titled “Creating Tasks”Task.from is an explicit alias for writing () => somePromise(). It’s mainly useful for clarity:
Transforming with map
Section titled “Transforming with map”map transforms the resolved value without running the Task:
Chaining maps builds a description of the transformation; the actual async work happens when you call the result.
Sequencing with chain
Section titled “Sequencing with chain”chain sequences two async operations where the second depends on the result of the first:
Each step waits for the previous one to resolve before starting.
Running Tasks in parallel with all
Section titled “Running Tasks in parallel with all”Task.all takes an array of Tasks and runs them simultaneously, collecting all results:
The return type is inferred from the input tuple — if you pass [Task<Config>, Task<string>], you
get back Task<[Config, string]>.
Easy mix-up: all runs every task simultaneously. If tasks must not overlap or order matters, use
sequential instead.
Racing Tasks with race
Section titled “Racing Tasks with race”Task.race starts all Tasks simultaneously and resolves as soon as the first one completes. The
others are abandoned — their results are ignored.
This is useful for hedged requests: fire the same request at multiple endpoints and accept the first response, reducing tail latency.
Running Tasks one at a time with sequential
Section titled “Running Tasks one at a time with sequential”When order matters or tasks must not overlap, Task.sequential runs each Task only after the
previous one resolves, collecting all results in an array:
Task.sequential is the counterpart to Task.all: use all when tasks are independent and can
overlap, and sequential when each step must complete before the next begins.
Delaying execution
Section titled “Delaying execution”Task.delay adds a pause before the Task runs:
Useful for debouncing or rate limiting.
Repeating Tasks
Section titled “Repeating Tasks”repeat and repeatUntil run a Task multiple times unconditionally — useful for scheduled polling
or heartbeats, not error recovery. This fits naturally with Task’s guarantee that it never fails.
Task.repeat runs a Task a fixed number of times and collects every result:
Task.repeatUntil keeps running until the result satisfies a predicate, then returns it. This is
the natural shape for polling:
Both accept an optional delay (in ms) inserted between runs. The delay is not applied after the
final run.
The Task family
Section titled “The Task family”Task<A> is for async operations that always succeed. When failure is possible, use the specialised
variants — for most async work that can fail, TaskResult is what you want:
TaskResult<E, A> — an async operation that can fail with a typed error. It’s
Task<Result<E, A>> under the hood:
Cancelling a multi-step chain
Section titled “Cancelling a multi-step chain”TaskResult.chain forwards the call-site signal to each inner step automatically. A single
AbortController at the call site reaches whichever request is currently in flight — no extra
wiring at each step:
You can add as many chain steps as the transaction requires. The signal propagates through all
of them without any extra wiring.
TaskMaybe<A> — an async operation that may return nothing. It’s Task<Maybe<A>>:
TaskMaybe.tryCatch catches any rejection and converts it to None — useful when you treat a
failed lookup the same as a missing value.
TaskValidation<E, A> — an async operation that accumulates errors. Used for async validation
where all checks should run regardless of individual failures.
TaskResult and TaskMaybe follow the same API conventions as their synchronous counterparts
(map, chain, match, getOrElse, recover). TaskValidation mirrors Validation —
ap instead of chain for error accumulation, plus product and productAll for combining
independent async validations. If you’ve used Result, TaskResult will be immediately familiar.
Running a Task
Section titled “Running a Task”A Task is just a function. To run it, call it — calling returns a Deferred<A>, which you can
await directly:
For TaskResult and TaskMaybe, the result is a wrapped value:
Most of the time you’ll call the pipeline at one point — the outer boundary where your application produces a final result or triggers a side effect.
When you need an explicit Promise<A> — for example, to pass to a third-party API that requires
one — convert the Deferred with Deferred.toPromise:
This is the only case where you need to reach for Deferred directly. Everywhere inside a pipe
chain, await task() is all you need.
When to use Task vs async/await
Section titled “When to use Task vs async/await”Use Task when:
- You want to build a pipeline of async steps that you can compose, pass around, or delay before executing
- You need parallel execution via
Task.all, first-wins viaTask.race, or ordered execution viaTask.sequential - You want typed error handling with
TaskResultinstead of try/catch around async functions
Keep using async/await directly when:
- The operation is a one-liner with no composition needed
- You’re inside a function body and the imperative style is clearer
- You’re working with code that isn’t pipeline-oriented
The two styles interoperate freely. Task.from(() => someAsyncFunction()) wraps any async function
into a Task, and await task() integrates back into any async/await context — the Deferred that
task() returns is thenable, so the runtime handles it exactly like a Promise.