UNPKG

true-myth

Version:

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

1,327 lines (1,161 loc) 60.1 kB
/** A {@linkcode Task Task<T, E>} is a type representing an asynchronous operation that may fail, with a successful (“resolved”) value of type `T` and an error (“rejected”) value of type `E`. If the `Task` is pending, it is {@linkcode Pending}. If it has resolved, it is {@linkcode Resolved Resolved(value)}. If it has rejected, it is {@linkcode Rejected Rejected(reason)}. For more, see [the guide](/guide/understanding/task/). @module */ import { curry1, identity, safeToString } from './-private/utils.js'; import Maybe from './maybe.js'; import Result, * as result from './result.js'; import Unit from './unit.js'; import * as delay from './task/delay.js'; // Make the `delay` namespace available as `task.delay` for convenience, and as // `task.Delay` for backward compatibility. This lets people do something like // `task.withRetries(aTask, task.delay.exponential(1_000).take(10))`. export { /** Re-exports `true-myth/task/delay` as a namespace object for convenience. ```ts import * as task from 'true-myth/task'; let strategy = task.delay.exponential({ from: 5, withFactor: 5 }).take(5); ``` */ delay, /** Re-exports `true-myth/task/delay` as a namespace object. @deprecated Use `delay` instead: ```ts import * as task from 'true-myth/task'; let strategy = task.delay.exponential({ from: 5, withFactor: 5 }).take(5); ``` The `Delay` namespace re-export will be removed in favor of the `delay` re-export in v10. */ delay as Delay, }; /** Internal implementation details for {@linkcode Task}. */ class TaskImpl { #promise; #state = [State.Pending]; /** Construct a new `Task`, using callbacks to wrap APIs which do not natively provide a `Promise`. This is identical to the [Promise][promise] constructor, with one very important difference: rather than producing a value upon resolution and throwing an exception when a rejection occurs like `Promise`, a `Task` always “succeeds” in producing a usable value, just like {@linkcode Result} for synchronous code. [promise]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/Promise For constructing a `Task` from an existing `Promise`, see: - {@linkcode fromPromise} - {@linkcode safelyTry} - {@linkcode tryOr} - {@linkcode tryOrElse} For constructing a `Task` immediately resolved or rejected with given values, see {@linkcode Task.resolve} and {@linkcode Task.reject} respectively. @param executor A function which the constructor will execute to manage the lifecycle of the `Task`. The executor in turn has two functions as parameters: one to call on resolution, the other on rejection. */ constructor(executor) { this.#promise = new Promise((resolve) => { executor((value) => { this.#state = [State.Resolved, value]; resolve(Result.ok(value)); }, (reason) => { this.#state = [State.Rejected, reason]; resolve(Result.err(reason)); }); }).catch((e) => { throw new TaskExecutorException(e); }); } // Implement `PromiseLike`; this allows `await someTask` to “just work” and to // produce the resulting `Result<A, B>`. It also powers the mechanics of things // like `andThen` below, since it makes it possible to use JS’ implicit // unwrapping of “thenables” to produce new `Task`s even when there is an // intermediate `Promise`. then(onSuccess, onRejected) { return this.#promise.then(onSuccess, onRejected); } toString() { switch (this.#state[0]) { case State.Pending: return 'Task.Pending'; case State.Resolved: return `Task.Resolved(${safeToString(this.#state[1])})`; case State.Rejected: return `Task.Rejected(${safeToString(this.#state[1])})`; /* v8 ignore next 2 */ default: unreachable(this.#state); } } // The implementation is intentionally vague about the types: we do not know // and do not care what the actual types in play are at runtime; we just need // to uphold the contract. Because the overload matches the types above, the // *call site* will guarantee the safety of the resulting types. static resolve(value) { // We produce `Unit` *only* in the case where no arguments are passed, so // that we can allow `undefined` in the cases where someone explicitly opts // into something like `Result<undefined, Blah>`. let result = arguments.length === 0 ? Unit : value; return new Task((resolve) => resolve(result)); } // The implementation is intentionally vague about the types: we do not know // and do not care what the actual types in play are at runtime; we just need // to uphold the contract. Because the overload matches the types above, the // *call site* will guarantee the safety of the resulting types. static reject(reason) { // We produce `Unit` *only* in the case where no arguments are passed, so // that we can allow `undefined` in the cases where someone explicitly opts // into something like `Result<Blah, undefined>`. let result = arguments.length === 0 ? Unit : reason; return new Task((_, reject) => reject(result)); } /** Create a pending `Task` and supply `resolveWith` and `rejectWith` helpers, similar to the [`Promise.withResolvers`][pwr] static method, but producing a `Task` with the usual safety guarantees. [pwr]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers ## Examples ### Resolution ```ts let { task, resolveWith, rejectWith } = Task.withResolvers<string, Error>(); resolveWith("Hello!"); let result = await task.map((s) => s.length); let length = result.unwrapOr(0); console.log(length); // 5 ``` ### Rejection ```ts let { task, resolveWith, rejectWith } = Task.withResolvers<string, Error>(); rejectWith(new Error("oh teh noes!")); let result = await task.mapRejection((s) => s.length); let errLength = result.isErr ? result.error : 0; console.log(errLength); // 5 ``` @group Constructors */ static withResolvers() { // SAFETY: immediately initialized via the `Task` constructor’s executor. let resolve; let reject; let task = new Task((resolveTask, rejectTask) => { resolve = resolveTask; reject = rejectTask; }); return { task, resolve, reject }; } get state() { return this.#state[0]; } get isPending() { return this.#state[0] === State.Pending; } get isResolved() { return this.#state[0] === State.Resolved; } get isRejected() { return this.#state[0] === State.Rejected; } /** The value of a resolved `Task`. > [!WARNING] > It is an error to access this property on a `Task` which is `Pending` or > `Rejected`. */ get value() { if (this.#state[0] === State.Resolved) { return this.#state[1]; } throw new InvalidAccess('value', this.#state[0]); } /** The cause of a rejection. > [!WARNING] > It is an error to access this property on a `Task` which is `Pending` or > `Resolved`. */ get reason() { if (this.#state[0] === State.Rejected) { return this.#state[1]; } throw new InvalidAccess('reason', this.#state[0]); } /** Map over a {@linkcode Task} instance: apply the function to the resolved value if the task completes successfully, producing a new `Task` with the value returned from the function. If the task failed, return the rejection as {@linkcode Rejected} without modification. `map` works a lot like [`Array.prototype.map`][array-map], but with one important difference. Both `Task` and `Array` are kind of like a “container” for other kinds of items, but where `Array.prototype.map` has 0 to _n_ items, a `Task` represents the possibility of an item being available at some point in the future, and when it is present, it is *either* a success or an error. [array-map]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map Where `Array.prototype.map` will apply the mapping function to every item in the array (if there are any), `Task.map` will only apply the mapping function to the resolved element if it is `Resolved`. If you have no items in an array of numbers named `foo` and call `foo.map(x => x + 1)`, you'll still some have an array with nothing in it. But if you have any items in the array (`[2, 3]`), and you call `foo.map(x => x + 1)` on it, you'll get a new array with each of those items inside the array "container" transformed (`[3, 4]`). With this `map`, the `Rejected` variant is treated *by the `map` function* kind of the same way as the empty array case: it's just ignored, and you get back a new `Task` that is still just the same `Rejected` instance. But if you have an `Resolved` variant, the map function is applied to it, and you get back a new `Task` with the value transformed, and still `Resolved`. ## Examples ```ts import Task from 'true-myth/task'; const double = n => n * 2; const aResolvedTask = Task.resolve(12); const mappedResolved = aResolvedTask.map(double); let resolvedResult = await aResolvedTask; console.log(resolvedResult.toString()); // Ok(24) const aRejectedTask = Task.reject("nothing here!"); const mappedRejected = aRejectedTask.map(double); let rejectedResult = await aRejectedTask; console.log(rejectedResult.toString()); // Err("nothing here!") ``` @template T The type of the resolved value. @template U The type of the resolved value of the returned `Task`. @param mapFn The function to apply the value to when the `Task` finishes if it is `Resolved`. */ map(mapFn) { return fromUnsafePromise(this.#promise.then(result.map(mapFn))); } /** Run a side effect with the wrapped value without modifying the {@linkcode Task}. This is useful for performing actions like logging, debugging, or other “side effects” external to the wrapped value. (**Note:** You should *never* mutate the value in the callback. Doing so will be extremely surprising to callers.) The function is only called if the `Task` is {@linkcode Resolved}, and the original `Task` is returned unchanged for further chaining. ```ts import * as task from 'true-myth/task'; const double = (n: number) => n * 2; const log = (value: unknown) => console.log(value); // Logs `42` then `84`, and returns `Ok(84)`. task.resolve<number, string>(42).inspect(log).map(double).inspect(log); // Does not log anything, and returns `Err('error')`. task.reject<number, string>('error').inspect(log).map(double).inspect(log); ``` @param fn The function to call with the resolved value. @returns The original `Task`, unchanged */ inspect(fn) { return fromUnsafePromise(this.#promise.then(result.inspect(fn))); } /** Run a side effect with the wrapped value without modifying the {@linkcode Task}. This is useful for performing actions like logging, debugging, or other “side effects” external to the wrapped value. (**Note:** You should *never* mutate the value in the callback. Doing so will be extremely surprising to callers.) The function is only called if the `Task` is {@linkcode Rejected}, and the original `Task` is returned unchanged for further chaining. ```ts import * as task from 'true-myth/task'; const double = (n: number) => n * 2; const log = (value: unknown) => console.log(value); // Logs `42` then `84`, and returns `Ok(84)`. task.resolve<number, string>(42) .inspectRejected(log) .map(double) .inspectRejected(log); // Does not log anything, and returns `Err('error')`. task.reject<number, string>('error') .inspectRejected(log) .map(double) .inspectRejected(log); ``` @param fn The function to call with the rejected value. @returns The original `Task`, unchanged */ inspectRejected(fn) { return fromUnsafePromise(this.#promise.then(result.inspectErr(fn))); } /** Map over a {@linkcode Task}, exactly as in {@linkcode map}, but operating on the rejection reason if the `Task` rejects, producing a new `Task`, still rejected, with the value returned from the function. If the task completed successfully, return it as `Resolved` without modification. This is handy for when you need to line up a bunch of different types of errors, or if you need an error of one shape to be in a different shape to use somewhere else in your codebase. ## Examples ```ts import Task from 'true-myth/task'; const extractReason = (err: { code: number, reason: string }) => err.reason; const aResolvedTask = Task.resolve(12); const mappedResolved = aResolvedTask.mapRejected(extractReason); console.log(mappedOk)); // Ok(12) const aRejectedTask = Task.reject({ code: 101, reason: 'bad file' }); const mappedRejection = await aRejectedTask.mapRejected(extractReason); console.log(toString(mappedRejection)); // Err("bad file") ``` @template T The type of the value produced if the `Task` resolves. @template E The type of the rejection reason if the `Task` rejects. @template F The type of the rejection for the new `Task`, returned by the `mapFn`. @param mapFn The function to apply to the rejection reason if the `Task` is rejected. */ mapRejected(mapFn) { return fromUnsafePromise(this.#promise.then(result.mapErr(mapFn))); } /** You can think of this like a short-circuiting logical "and" operation on a {@linkcode Task}. If this `task` resolves, then the output is the task passed to the method. If this `task` rejects, the result is its rejection reason. This is useful when you have another `Task` value you want to provide if and *only if* the first task resolves successfully – that is, when you need to make sure that if you reject, whatever else you're handing a `Task` to *also* gets that {@linkcode Rejected}. Notice that, unlike in {@linkcode map Task.prototype.map}, the original `task` resolution value is not involved in constructing the new `Task`. ## Comparison with `andThen` When you need to perform tasks in sequence, use `andThen` instead: it will only run the function that produces the next `Task` if the first one resolves successfully. You should only use `and` when you have two `Task` instances running concurrently and only need the value from the second if they both resolve. ## Examples Using `and` to get new `Task` values from other `Task` values: ```ts import Task from 'true-myth/task'; let resolvedA = Task.resolve<string, string>('A'); let resolvedB = Task.resolve<string, string>('B'); let rejectedA = Task.reject<string, string>('bad'); let rejectedB = Task.reject<string, string>('lame'); let aAndB = resolvedA.and(resolvedB); await aAndB; let aAndRA = resolvedA.and(rejectedA); await aAndRA; let raAndA = rejectedA.and(resolvedA); await raAndA; let raAndRb = rejectedA.and(rejectedB); await raAndRb; expect(aAndB.toString()).toEqual('Task.Resolved("B")'); expect(aAndRA.toString()).toEqual('Task.Rejected("bad")'); expect(raAndA.toString()).toEqual('Task.Rejected("bad")'); expect(raAndRb.toString()).toEqual('Task.Rejected("bad")'); ``` Using `and` to get new `Task` values from a `Result`: ```ts import Task from 'true-myth/task'; let resolved = Task.resolve<string, string>('A'); let rejected = Task.reject<string, string>('bad'); let ok = Result.ok<string, string>('B'); let err = Result.err<string, string>('lame'); let aAndB = resolved.and(ok); await aAndB; let aAndRA = resolved.and(err); await aAndRA; let raAndA = rejected.and(ok); await raAndA; let raAndRb = rejected.and(err); await raAndRb; expect(aAndB.toString()).toEqual('Task.Resolved("B")'); expect(aAndRA.toString()).toEqual('Task.Rejected("bad")'); expect(raAndA.toString()).toEqual('Task.Rejected("bad")'); expect(raAndRb.toString()).toEqual('Task.Rejected("bad")'); ``` @template U The type of the value for a resolved version of the `other` `Task`, i.e., the success type of the final `Task` present if the first `Task` is `Ok`. @param other The `Task` instance to return if `this` is `Rejected`. */ and(other) { return new Task((resolve, reject) => { this.#promise.then(result.match({ Ok: (_) => { if (result.isInstance(other)) { other.match({ Ok: resolve, Err: reject, }); } else { other.#promise.then(result.match({ Ok: resolve, Err: reject, })); } }, Err: reject, })); }); } andThen(thenFn) { return new Task((resolve, reject) => { this.#promise.then(result.match({ Ok: (value) => { const thenResult = thenFn(value); if (result.isInstance(thenResult)) { thenResult.match({ Ok: resolve, Err: reject, }); } else { // This is a little annoying: there is no direct way to return the // resulting `Task` value here because of the intermediate `Promise` // and the resulting asynchrony. This is a direct consequence of // the fact that what `Task` is, `Promise` really should be in the // first place! We have to basically “unwrap” the inner `Result`. // To do that, though, we have to wait for the intermediate // `Promise` to resolve. Only then is the inner `Result` available // to use with the top-most `Task`’s resolution/rejection callback // functions! thenResult.#promise.then(result.match({ Ok: resolve, Err: reject, })); } }, Err: reject, })); }); } /** Provide a fallback for a given {@linkcode Task}. Behaves like a logical `or`: if the `task` value is {@linkcode Resolved}, returns that `task` unchanged, otherwise, returns the `other` `Task`. This is useful when you want to make sure that something which takes a `Task` always ends up getting a {@linkcode Resolved} variant, by supplying a default value for the case that you currently have an {@linkcode Rejected}. ## Comparison with `orElse` When you need to run a `Task` in sequence if another `Task` rejects, use `orElse` instead: it will only run the function that produces the next `Task` if the first one rejects. You should only use `or` when you have two `Task` instances running concurrently and only need the value from the second if the first rejects. ## Examples Using `or` to get new `Task` values from other `Task` values: ```ts import Task from 'true-myth/task'; const resolvedA = Task.resolve<string, string>('a'); const resolvedB = Task.resolve<string, string>('b'); const rejectedWat = Task.reject<string, string>(':wat:'); const rejectedHeaddesk = Task.reject<string, string>(':headdesk:'); console.log(resolvedA.or(resolvedB).toString()); // Resolved("a") console.log(resolvedA.or(rejectedWat).toString()); // Resolved("a") console.log(rejectedWat.or(resolvedB).toString()); // Resolved("b") console.log(rejectedWat.or(rejectedHeaddesk).toString()); // Rejected(":headdesk:") ``` Using `or` to get new `Task` values from `Result` values: ```ts import Task from 'true-myth/task'; import Result from 'true-myth/result'; const resolved = Task.resolve<string, string>('resolved'); const rejected = Task.reject<string, string>('rejected'); const ok = Result.ok<string, string>('ok'); const err = Result.err<string, string>('err'); console.log(resolved.or(ok).toString()); // Resolved("resolved") console.log(resolved.or(err).toString()); // Resolved("err") console.log(rejected.or(ok).toString()); // Resolved("ok") console.log(rejected.or(err).toString()); // Rejected("err") ``` @template F The type wrapped in the `Rejected` case of `other`. @param other The `Result` to use if `this` is `Rejected`. @returns `this` if it is `Resolved`, otherwise `other`. */ or(other) { return new Task((resolve, reject) => { this.#promise.then(result.match({ Ok: resolve, Err: (_) => { if (result.isInstance(other)) { other.match({ Ok: resolve, Err: reject, }); } else { other.#promise.then(result.match({ Ok: resolve, Err: reject, })); } }, })); }); } orElse(elseFn) { return new Task((resolve, reject) => { this.#promise.then(result.match({ Ok: resolve, Err: (reason) => { const thenResult = elseFn(reason); if (result.isInstance(thenResult)) { thenResult.match({ Ok: resolve, Err: reject, }); } else { thenResult.#promise.then(result.match({ Ok: resolve, Err: reject, })); } }, })); }); } /** Allows you to produce a new value by providing functions to operate against both the {@linkcode Resolved} and {@linkcode Rejected} states once the {@linkcode Task} resolves. (This is a workaround for JavaScript’s lack of native pattern-matching.) ## Example ```ts import Task from 'true-myth/task'; let theTask = new Task<number, Error>((resolve, reject) => { let value = Math.random(); if (value > 0.5) { resolve(value); } else { reject(new Error(`too low: ${value}`)); } }); // Note that we are here awaiting the `Promise` returned from the `Task`, // not the `Task` itself. await theTask.match({ Resolved: (num) => { console.log(num); }, Rejected: (err) => { console.error(err); }, }); ``` This can both be used to produce side effects (as here) and to produce a value regardless of the resolution/rejection of the task, and is often clearer than trying to use other methods. Thus, this is especially convenient for times when there is a complex task output. > [!NOTE] > You could also write the above example like this, taking advantage of how > awaiting a `Task` produces its inner `Result`: > > ```ts > import Task from 'true-myth/task'; > > let theTask = new Task<number, Error>((resolve, reject) => { > let value = Math.random(); > if (value > 0.5) { > resolve(value); > } else { > reject(new Error(`too low: ${value}`)); > } > }); > > let theResult = await theTask; > theResult.match({ > Ok: (num) => { > console.log(num); > }, > Err: (err) => { > console.error(err); > }, > }); > ``` > > Which of these you choose is a matter of taste! @param matcher A lightweight object defining what to do in the case of each variant. */ match(matcher) { return this.#promise.then(result.match({ Ok: matcher.Resolved, Err: matcher.Rejected, })); } /** Attempt to run this {@linkcode Task} to completion, but stop if the passed {@linkcode Timer}, or one constructed from a passed time in milliseconds, elapses first. If this `Task` and the duration happen to have the same duration, `timeout` will favor this `Task` over the timeout. @param timerOrMs A {@linkcode Timer} or a number of milliseconds to wait for this task before timing out. @returns A `Task` which has the resolution value of `this` or a `Timeout` if the timer elapsed. */ timeout(timerOrMs) { let timerTask = typeof timerOrMs === 'number' ? timer(timerOrMs) : timerOrMs; let timeout = timerTask.andThen((ms) => Task.reject(new Timeout(ms))); return race([this, timeout]); } /** Get the underlying `Promise`. Useful when you need to work with an API which *requires* a `Promise`, rather than a `PromiseLike`. Note that this maintains the invariants for a `Task` *up till the point you call this function*. That is, because the resulting promise was managed by a `Task`, it always resolves successfully to a `Result`. However, calling then `then` or `catch` methods on that `Promise` will produce a *new* `Promise` for which those guarantees do not hold. > [!IMPORTANT] > If the resulting `Promise` ever rejects, that is a ***BUG***, and you > should [open an issue](https://github.com/true-myth/true-myth/issues) so > we can fix it! */ toPromise() { return this.#promise; } /** Given a nested `Task`, remove one layer of nesting. For example, given a `Task<Task<string, E2>, E1>`, the resulting type after using this method will be `Task<string, E1 | E2>`. ## Note This method only works when the value wrapped in `Task` is another `Task`. If you have a `Task<string, E>` or `Task<number, E>`, this method won't work. If you have a `Task<Task<string, E2>, E1>`, then you can call `.flatten()` to get back a `Task<string, E1 | E2>`. ## Examples ```ts import * as task from 'true-myth/task'; const nested = task.resolve(task.resolve('hello')); const flattened = nested.flatten(); // Task<string, never> await flattened; console.log(flattened); // `Resolved('hello')` const nestedError = task.resolve(task.reject('inner error')); const flattenedError = nestedError.flatten(); // Task<never, string> await flattenedError; console.log(flattenedError); // `Rejected('inner error')` const errorNested = task.reject<Task<string, string>, string>('outer error'); const flattenedOuter = errorNested.flatten(); // Task<string, string> await flattenedOuter; console.log(flattenedOuter); // `Rejected('outer error')` ``` */ flatten() { return this.andThen(identity); } } /** Create a {@linkcode Task} which will resolve to the number of milliseconds the timer waited for that time elapses. (In other words, it safely wraps the [`setTimeout`][setTimeout] function.) [setTimeout]: https://developer.mozilla.org/en-US/docs/Web/API/Window/setTimeout This can be used as a “timeout” by calling it in conjunction any of the {@linkcode Task} helpers like {@linkcode all}, {@linkcode race}, and so on. As a convenience to use it as a timeout for another task, you can also combine it with the {@linkcode Task.timeout} instance method or the standalone {@linkcode timeout} function. Provides the requested duration of the timer in case it is useful for working with multiple timers. @param ms The number of milliseconds to wait before resolving the `Task`. @returns a Task which resolves to the passed-in number of milliseconds. */ export function timer(ms) { return new Task((resolve) => setTimeout(() => resolve(ms), ms)); } export function all(tasks) { if (tasks.length === 0) { return Task.resolve([]); } let total = tasks.length; let oks = Array.from({ length: tasks.length }); let resolved = 0; let hasRejected = false; return new Task((resolve, reject) => { // Because all tasks will *always* resolve, we need to manage this manually, // rather than using `Promise.all`, so that we produce a rejected `Task` as // soon as *any* `Task` rejects. for (let [idx, task] of tasks.entries()) { // Instead, each `Task` wires up handlers for resolution and rejection. task.match({ // If it rejected, then check whether one of the other tasks has already // rejected. If so, there is nothing to do. Otherwise, *this* task is // the first to reject, so we reject the overall `Task` with the reason // for this one, and flag that the `Task` is rejected. Rejected: (reason) => { if (hasRejected) { return; } hasRejected = true; reject(reason); }, // If it resolved, the same rule applies if one of the other tasks has // rejected, because the`Task` for this `any` will already be rejected // with that task’s rejection reason. Otherwise, we will add this value // to the bucket of resolutions, and track whether *all* the tasks have // resolved. If or when we get to that point, we resolve with the full // set of values. Resolved: (value) => { if (hasRejected) { return; } oks[idx] = value; resolved += 1; if (resolved === total) { resolve(oks); } }, }); } }); } export function allSettled(tasks) { // All task promises should resolve; none should ever reject, by definition. // The “settled” state here is represented by the `Task` itself, *not* by the // `Promise` rejection. This means the logic of `allSettled` is actually just // `Promise.all`! return new Task((resolve) => { Promise.all(tasks).then(resolve); }); } export function any(tasks) { if (tasks.length === 0) { return Task.reject(new AggregateRejection([])); } let total = tasks.length; let hasResolved = false; let rejections = Array.from({ length: tasks.length }); let rejected = 0; return new Task((resolve, reject) => { // We cannot use `Promise.any`, because it will only return the first `Task` // that resolves, and the `Promise` for a `Task` *always* either resolves if // it settles. for (let [idx, task] of tasks.entries()) { // Instead, each `Task` wires up handlers for resolution and rejection. task.match({ // If it resolved, then check whether one of the other tasks has already // resolved. If so, there is nothing to do. Otherwise, *this* task is // the first to resolve, so we resolve the overall `Task` with the value // for this one, and flag that the `Task` is resolved. Resolved: (value) => { if (hasResolved) { return; } hasResolved = true; resolve(value); }, // If it rejected, the same rule applies if one of the other tasks has // successfully resolved, because the`Task` for this `any` will already // have resolved to that task. Otherwise, we will add this rejection to // the bucket of rejections, and track whether *all* the tasks have // rejected. If or when we get to that point, we reject with the full // set of rejections. Rejected: (reason) => { if (hasResolved) { return; } rejections[idx] = reason; rejected += 1; if (rejected === total) { reject(new AggregateRejection(rejections)); } }, }); } }); } export function race(tasks) { if (tasks.length === 0) { return new Task(() => { /* pending forever, just like `Promise.race` */ }); } return new Task((resolve, reject) => { Promise.race(tasks).then((result) => result.match({ Ok: resolve, Err: reject, })); }); } /** An error type produced when {@linkcode any} produces any rejections. All rejections are aggregated into this type. > [!NOTE] > This error type is not allowed to be subclassed. @template E The type of the rejection reasons. */ export class AggregateRejection extends Error { errors; name = 'AggregateRejection'; constructor(errors) { super('`Task.any`'); this.errors = errors; } toString() { let internalMessage = this.errors.length > 0 ? `[${safeToString(this.errors)}]` : 'No tasks'; return super.toString() + `: ${internalMessage}`; } } export const State = { Pending: 'Pending', Resolved: 'Resolved', Rejected: 'Rejected', }; /** The error thrown when an error is thrown in the executor passed to {@linkcode Task.constructor}. This error class exists so it is clear exactly what went wrong in that case. @group Errors */ export class TaskExecutorException extends Error { name = 'TrueMyth.Task.ThrowingExecutor'; constructor(originalError) { super('The executor for `Task` threw an error. This cannot be handled safely.', { cause: originalError, }); } } /** An error thrown when the `Promise<Result<T, E>>` passed to {@link fromUnsafePromise} rejects. @group Errors */ export class UnsafePromise extends Error { name = 'TrueMyth.Task.UnsafePromise'; constructor(unhandledError) { let explanation = 'If you see this message, it means someone constructed a True Myth `Task` with a `Promise<Result<T, E>` but where the `Promise` could still reject. To fix it, make sure all calls to `Task.fromUnsafePromise` have a `catch` handler. Never use `Task.fromUnsafePromise` with a `Promise` on which you cannot verify by inspection that it was created with a catch handler.'; super(`Called 'Task.fromUnsafePromise' with an unsafe promise.\n${explanation}`, { cause: unhandledError, }); } } export class InvalidAccess extends Error { name = 'TrueMyth.Task.InvalidAccess'; constructor(field, state) { super(`Tried to access 'Task.${field}' when its state was '${state}'`); } } /* v8 ignore next 3 */ function unreachable(value) { throw new Error(`Unexpected value: ${value}`); } // Duplicate documentation because it will show up more nicely when rendered in // TypeDoc than if it applies to only one or the other; using `@inheritdoc` will // also work but works less well in terms of how editors render it (they do not // process that “directive” in general). /** A `Task` is a type safe asynchronous computation. 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 from a “normal” `Promise`: 1. A `Task` *cannot* “reject”. All errors must be handled. This means that, like a {@linkcode 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 {@linkcode result Result<T, E>}. @class */ export const Task = TaskImpl; export default Task; /** An `Error` type representing a timeout, as when a {@linkcode Timer} elapses. */ class Timeout extends Error { ms; #duration; get duration() { return this.#duration; } constructor(ms) { super(`Timed out after ${ms} milliseconds`); this.ms = ms; this.#duration = ms; } } /** Standalone function version of {@linkcode Task.resolve} */ export const resolve = Task.resolve; /** Standalone function version of {@linkcode Task.reject} */ export const reject = Task.reject; /** Standalone function version of {@linkcode Task.withResolvers} */ export const withResolvers = Task.withResolvers; export function fromPromise(promise, onRejection) { let handleError = onRejection ?? identity; return new Task((resolve, reject) => { promise.then(resolve, (reason) => reject(handleError(reason))); }); } /** Build a {@linkcode Task Task<T, E>} from a {@linkcode Result Result<T, E>}. > [!IMPORTANT] > This does not (and by definition cannot) handle errors that happen during > construction of the `Result`, because those happen before this is called. > See {@linkcode tryOr} and {@linkcode tryOrElse} as well as the corresponding > {@linkcode "result".tryOr result.tryOr} and {@linkcode "result".tryOrElse > result.tryOrElse} methods for synchronous functions. ## Examples Given an {@linkcode "result".Ok Ok<T, E>}, `fromResult` will produces a {@linkcode Resolved Resolved<T, E>} task. ```ts import { fromResult } from 'true-myth/task'; import { ok } from 'true-myth/result'; let successful = fromResult(ok("hello")); // -> Resolved("hello") ``` Likewise, given an `Err`, `fromResult` will produces a {@linkcode Rejected} task. ```ts import { fromResult } from 'true-myth/task'; import { err } from 'true-myth/result'; let successful = fromResult(err("uh oh!")); // -> Rejected("uh oh!") ``` It is often clearest to access the function via a namespace-style import: ```ts import * as task from 'true-myth/task'; import { ok } from 'true-myth/result'; let theTask = task.fromResult(ok(123)); ``` As an alternative, it can be useful to rename the import: ```ts import { fromResult: taskFromResult } from 'true-myth/task'; import { err } from 'true-myth/result'; let theTask = taskFromResult(err("oh no!")); ``` */ export function fromResult(result) { return new Task((resolve, reject) => result.match({ Ok: resolve, Err: reject, })); } /** Produce a `Task<T, E>` from a promise of a {@linkcode Result Result<T, E>}. > [!WARNING] > This constructor assumes you have already correctly handled the promise > rejection state, presumably by mapping it into the wrapped `Result`. It is > *unsafe* for this promise ever to reject! You should only ever use this > with `Promise<Result<T, E>>` you have created yourself (including via a > `Task`, of course). > > For any other `Promise<Result<T, E>>`, you should first attach a `catch` > handler which will also produce a `Result<T, E>`. > > If you call this with an unmanaged `Promise<Result<T, E>>`, that is, one > that has *not* correctly set up a `catch` handler, the rejection will > throw an {@linkcode UnsafePromise} error that will ***not*** be catchable > by awaiting the `Task` or its original `Promise`. This can cause test > instability and unpredictable behavior in your application. @param promise The promise from which to create the `Task`. @group Constructors */ export function fromUnsafePromise(promise) { return new Task((resolve, reject) => { promise.then(result.match({ Ok: resolve, Err: reject, }), (rejectionReason) => { throw new UnsafePromise(rejectionReason); }); }); } /** Given a function which takes no arguments and returns a `Promise`, return a {@linkcode Task Task<T, unknown>} for the result of invoking that function. This safely handles functions which fail synchronously or asynchronously, so unlike {@linkcode fromPromise} is safe to use with values which may throw errors _before_ producing a `Promise`. ## Examples ```ts import { safelyTry } from 'true-myth/task'; function throws(): Promise<T> { throw new Error("Uh oh!"); } // Note: passing the function by name, *not* calling it. let theTask = safelyTry(throws); let theResult = await theTask; console.log(theResult.toString()); // Err(Error: Uh oh!) ``` @param fn A function which returns a `Promise` when called. @returns A `Task` which resolves to the resolution value of the promise or rejects with the rejection value of the promise *or* any error thrown while invoking `fn`. */ export function safelyTry(fn) { return new Task((resolve, reject) => { try { fn().then(resolve, reject); } catch (e) { reject(e); } }); } export function tryOr(rejection, fn) { const op = (curriedFn) => new Task((resolve, reject) => { try { curriedFn().then(resolve, (_reason) => reject(rejection)); } catch (_e) { reject(rejection); } }); return curry1(op, fn); } /** An alias for {@linkcode tryOr} for ease of migrating from v8.x to v9.x. > [!TIP] > You should switch to {@linkcode tryOr}. We expect to deprecate and remove > this alias at some point! */ export const safelyTryOr = tryOr; /** An alias for {@linkcode tryOrElse} for ease of migrating from v8.x to v9.x. > [!TIP] > You should switch to {@linkcode tryOrElse}. We expect to deprecate and > remove this alias at some point! */ export const safelyTryOrElse = tryOrElse; export function tryOrElse(onError, fn) { const op = (fn) => new Task((resolve, reject) => { try { fn().then(resolve, (reason) => reject(onError(reason))); } catch (error) { reject(onError(error)); } }); return curry1(op, fn); } export function safe(fn, onError) { let handleError = onError ?? identity; return (...params) => tryOrElse(handleError, () => fn(...params)); } export function safeNullable(fn, onError) { let handleError = onError ?? identity; return (...params) => tryOrElse(handleError, async () => { let theValue = (await fn(...params)); return Maybe.of(theValue); }); } export function map(mapFn, task) { return curry1((task) => task.map(mapFn), task); } export function inspect(fn, task) { return curry1((task) => task.inspect(fn), task); } export function inspectRejected(fn, task) { return curry1((task) => task.inspectRejected(fn), task); } export function mapRejected(mapFn, task) { return curry1((task) => task.mapRejected(mapFn), task); } export function and(andTask, task) { return curry1((task) => task.and(andTask), task); } export function andThen(thenFn, task) { return curry1((task) => task.andThen(thenFn), task); } export function or(other, task) { return curry1((task) => task.or(other), task); } export function orElse(elseFn, task) { return curry1((task) => task.orElse(elseFn), task); } export function match(matcher, task) { return curry1((task) => task.match(matcher), task); } export function timeout(timerOrMs, task) { return curry1((task) => task.timeout(timerOrMs), task); } /** Standalone version of {@linkcode Task.toPromise Task.prototype.toPromise}. @template T The type of the value when the `Task` resolves successfully. @template E The type of the rejection reason when the `Task` rejects. */ export function toPromise(task) { return task.toPromise(); } /** Given a nested `Task`, remove one layer of nesting. For example, given a `Task<Task<string, E2>, E1>`, the resulting type after using this function will be `Task<string, E1 | E2>`. ## Note This function only works when the value wrapped in `Task` is another `Task`. If you have a `Task<string, E>` or `Task<number, E>`, this function won't work. If you have a `Task<Task<string, E2>, E1>`, then you can call `.flatten()` to get back a `Task<string, E1 | E2>`. ## Examples ```ts import * as task from 'true-myth/task'; const nested = task.resolve(task.resolve('hello')); const flattened = task.flatten(nested); // Task<string, never> await flattened; console.log(flattened); // `Resolved('hello')` const nestedError = task.resolve(task.reject('inner error')); const flattenedError = task.flatten(nestedError); // Task<never, string> await flattenedError; console.log(flattenedError); // `Rejected('inner error')` const errorNested = task.reject<Task<string, string>, string>('outer error'); const flattenedOuter = task.flatten(errorNested); // Task<string, string> await flattenedOuter; console.log(flattenedOuter); // `Rejected('outer error')` ``` */ export function flatten(nestedTask) { // Uses `andThen` directly rather than calling `.flatten()` to avoid an extra // function dispatch. return nestedTask.andThen(identity); } /** Execute a callback that produces either a {@linkcode Task} or the “sentinel” [`Error`][error-mdn] subclass {@linkcode StopRetrying}. `withRetries` retries the `retryable` callback until the retry strategy is exhausted *or* until the callback returns either `StopRetrying` or a `Task` that rejects with `StopRetrying`. If no strategy is supplied, a default strategy of retrying immediately up to three times is used. [error-mdn]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error The `strategy` is any iterable iterator that produces an integral number, which is used as the number of milliseconds to delay before retrying the `retryable`. When the `strategy` stops yielding values, this will produce a {@linkcode Rejected} `Task` whose rejection value is an instance of {@linkcode RetryFailed}. Returning `stopRetrying()` from the top-level of the function or as the rejection reason will also produce a rejected `Task` whose rejection value is an instance of `RetryFailed`, but will also immediately stop all further retries and will include the `StopRetrying` instance as the `cause` of the `RetryFailed` instance. You can determine whether retries stopped because the strategy was exhausted or because `stopRetrying` was called by checking the `cause` on the `RetryFailed` instance. It will be `undefined` if the the `RetryFailed` was the result of the strategy being exhausted. It will be a `StopRetrying` if it stopped because the caller returned `stopRetrying()`. ## Examples ### Retrying with backoff When attempting to fetch data from a server, you might want to retry if and only if the response was an HTTP 408 response, indicating that there was a timeout but that the client is allowed to try again. For other error codes, it will simply reject immediately. ```ts import * as task from 'true-myth/task'; import * as delay from 'true-myth/task/delay'; let theTask = task.withRetries( () => task.fromPromise(fetch('https://example.com')).andThen((res) => { if (res.status === 200) { return task.fromPromise(res.json()); } else if (res.status === 408) { return task.reject(res.statusText); } else { return task.stopRetrying(res.statusText); } }), delay.fibonacci().map(delay.jitter).take(10) ); ``` Here, this uses a Fibonacci backoff strategy, which can be preferable in some cases to a classic exponential backoff strategy (see [A Performance Comparison of Different Backoff Algorithms under Different Rebroadcast Probabilities for MANET's][pdf] for more details). [pdf]: https://www.res