Task
🚧 Under Construction 🚧
There will be more, different, and better, content here Soon™. We didn’t want to block getting the new docs site live on having finished updating all the existing content!
A Task<T, E>
is a type representing the state of an asynchronous computation which may fail, with a successful value of type T
or an error of type E
. It has three states:
Pending
Resolved
, with a value of typeT
Rejected
, with a reason of typeE
In general, however, because of the asynchronous nature of a Task
, you will interact with it via its methods, rather than matching on its state, since you generally want to perform an operation once it has resolved.
You can think of a Task<T, E>
as being basically a Promise<Result<T, E>>
, because it is a Promise<Result<T, E>>
under the hood, but with two main differences:
A
Task
cannot reject. All rejections must be handled. This means that, like aResult
, it will never throw an error if used in strict TypeScript.Unlike
Promise
,Task
robustly distinguishes betweenmap
andandThen
operations.
Task
also implements JavaScript’s PromiseLike
interface, so you canawait
it; when a Task<T, E>
is awaited, it produces a Result<T, E>
.
Creating a Task
The simplest way to create a Task
is to call Task.fromPromise(somePromise)
. Because any promise may reject/throw an error, this simplest form catches all rejections and maps them into the Rejected
variant. Given a Promise<T>
, the resulting Task
thus has the type Task<T, unknown>
. For example:
let { promise, reject } = Promise.withResolvers<number>();
// `theTask` has the type `Task<number, unknown>`
let theTask = Task.fromPromise(promise);
// The rejection will always produce
reject("Tasks always safely handle errors!");
await theTask;
console.log(theTask.state); // State.Rejected
// The `reason` here is of type `unknown`. Attempting to access it on a pending
// or resolved `Task` (rather than a rejected `Task`) will throw an error.
console.log(theTask.reason); // "Tasks always safely handle errors!"
You can also provide a fallback value for the error using tryOr
:
let { promise, reject } = Promise.withResolvers<number>();
// `theTask` has the type `Task<number, string>`
let theTask = Task.tryOr(promise, "a fallback error");
reject({ thisStructuredObject: "will be ignored!" });
await theTask;
console.log(theTask.reason); // "a fallback error"
You can use Task.tryOrElse
to produce a known rejection reason from the unknown
rejection reason of a Promise
:
let { promise, reject } = Promise.withResolvers<number>();
// `theTask` has the type `Task<number, Error>`
let theTask = Task.tryOrElse(
promise,
(reason) => new Error("Promise was rejected", { cause: reason })
);
Task
also has resolved
and rejected
static helpers:
// `resolved` has the type `Task<number, never>`
let resolved = Task.resolved(123);
// `rejected` has the type `Task<never, string>`
let rejected = Task.rejected("something went wrong");
Working with a Task
There are many helpers (“combinators”) for working with a Task
. The most common are map
, mapRejected
, andThen
, and orElse
.
map
transforms a value “within” aTask
context:tslet theTask = Task.resolved(123); let doubled = theTask.map((n) => n * 2); let theResult = await doubled; console.log(theResult); // Ok(456)
mapRejected
does the same, but for a rejection:tslet theTask = Task.rejected(new Error("ugh")); let wrapped = theTask.mapRejected( (err) => new Error(`sigh (caused by: ${err.message})`) ); let theResult = await wrapped; console.log(theResult); // Err("Error: sigh (caused by: ugh)")
andThen
uses the value produced by one resolvedTask
to create anotherTask
, but without nesting them.orElse
is likeandThen
, but for theRejection
. You can often combine them to good effect. For example, a safefetch
usage might look like this:tslet fetchUsersTask = Task.try(fetch(/* some endpoint */)) .orElse(handleError('http')) .andThen((res) => Task.try(res.json().orElse(handleError('parse'))) .match({ Resolved: (users) => { for (let user of users) { console.log(user); } }, Rejected: (error) => { let currentError = error; console.error(currentError.message) while (currentError = currentError.cause) { console.error(currentError.message); } }, }); let usersResult = await fetchUsersTask; usersResult.match({ Ok: (users) => { for (let user of users) { console.log(user); } }, Err: (error) => { let currentError = error; console.error(currentError.message) while (currentError = currentError.cause) { console.error(currentError.message); } } }); function handleError(name: string): (error: unknown) => Error { return new Error(`my-lib.${name}`, { cause: error }); }
There are many others; see the API docs!
Timing
Because Task
wraps Promise
, it (currently) always requires at least two ticks of the microtask queue before it will produce its final state. In practical terms, you must always await
a Task
before its state
will be Resolved
or Rejected
, even with Task.resolved
and Task.rejected
. If the (Stage 1) Faster Promise Adoption TC39 proposal is adopted, this may change/improve.