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:
PendingResolved, with a value of typeTRejected, 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
Taskcannot reject. All rejections must be handled. This means that, like aResult, it will never throw an error if used in strict TypeScript.Unlike
Promise,Taskrobustly distinguishes betweenmapandandThenoperations.
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.resolve(123);
// `rejected` has the type `Task<never, string>`
let rejected = Task.reject("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.
maptransforms a value “within” aTaskcontext:tslet theTask = Task.resolve(123); let doubled = theTask.map((n) => n * 2); let theResult = await doubled; console.log(theResult); // Ok(456)mapRejecteddoes the same, but for a rejection:tslet theTask = Task.reject(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)")andThenuses the value produced by one resolvedTaskto create anotherTask, but without nesting them.orElseis likeandThen, but for theRejection. You can often combine them to good effect. For example, a safefetchusage 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.resolve and Task.reject. If the (Stage 1) Faster Promise Adoption TC39 proposal is adopted, this may change/improve.