Task — lazy async
Task<A> is an async computation with two guarantees: it is lazy (nothing runs until you call
it) and infallible (it always resolves — it never rejects). When failure is possible, that
failure is encoded in the return type using TaskResult<E, A> rather than leaking out as a rejected
Promise.
The problems with Promises
Section titled “The problems with Promises”Promises have two quirks that make them hard to compose.
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]>.
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”Unlike retry — which re-runs a computation in response to failure — repeat and repeatUntil run a
Task multiple times unconditionally. 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:
TaskResult<E, A> — an async operation that can fail with a typed error. It’s
Task<Result<E, A>> under the hood:
TaskOption<A> — an async operation that may return nothing. It’s Task<Option<A>>:
TaskOption.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.
All three follow the same API conventions as their synchronous counterparts (map, chain,
match, getOrElse, recover). 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 TaskOption, 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.allwithin a pipeline - 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.