UNPKG

true-myth

Version:

A library for safe functional programming in JavaScript, with first-class support for TypeScript

149 lines (109 loc) 5.51 kB
# Task 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 type `T` - `Rejected`, with a reason of type `E` 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: 1. A `Task` cannot *reject*. All rejections must be handled. This means that, like a `Result`, it will *never* throw an error if used in strict TypeScript. 2. Unlike `Promise`, `Task` robustly distinguishes between `map` and `andThen` operations. `Task` also implements JavaScript’s `PromiseLike` interface, so you can`await` 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.try(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: ```ts let { promise, reject } = Promise.withResolvers<number>(); // `theTask` has the type `Task<number, unknown>` let theTask = Task.try(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`: ```ts 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`: ```ts 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: ```ts // `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” a `Task` context: ```ts let 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: ```ts let 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 resolved `Task` to create another `Task`, but without nesting them. `orElse` is like `andThen`, but for the `Rejection`. You can often combine them to good effect. For example, a safe `fetch` usage might look like this: ```ts let 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][fpa] TC39 proposal is adopted, this *may* change/improve. [fpa]: https://github.com/tc39/proposal-faster-promise-adoption