true-myth
Version:
A library for safe functional programming in JavaScript, with first-class support for TypeScript
1,274 lines (1,125 loc) • 57.7 kB
JavaScript
"use strict";
/**
{@include doc/task.md}
@module
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.isRetryFailed = exports.RETRY_FAILED_NAME = exports.stopRetrying = exports.withRetries = exports.toPromise = exports.timeout = exports.match = exports.orElse = exports.or = exports.andThen = exports.and = exports.mapRejected = exports.map = exports.safeNullable = exports.safe = exports.safelyTryOrElse = exports.safelyTryOr = exports.safelyTry = exports.fromUnsafePromise = exports.fromResult = exports.fromPromise = exports.withResolvers = exports.reject = exports.resolve = exports.Task = exports.tryOrElse = exports.tryOr = exports.InvalidAccess = exports.UnsafePromise = exports.TaskExecutorException = exports.State = exports.AggregateRejection = exports.race = exports.any = exports.allSettled = exports.all = exports.timer = exports.Delay = void 0;
const utils_js_1 = require("./-private/utils.cjs");
const maybe_js_1 = require("./maybe.cjs");
const result_js_1 = require("./result.cjs");
const unit_js_1 = require("./unit.cjs");
const Delay = require("./task/delay.cjs");
exports.Delay = Delay;
/**
Internal implementation details for {@linkcode Task}.
*/
class TaskImpl {
#promise;
#state = [exports.State.Pending];
constructor(executor) {
this.#promise = new Promise((resolve) => {
executor((value) => {
this.#state = [exports.State.Resolved, value];
resolve(result_js_1.default.ok(value));
}, (reason) => {
this.#state = [exports.State.Rejected, reason];
resolve(result_js_1.default.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 exports.State.Pending:
return 'Task.Pending';
case exports.State.Resolved:
return `Task.Resolved(${(0, utils_js_1.safeToString)(this.#state[1])})`;
case exports.State.Rejected:
return `Task.Rejected(${(0, utils_js_1.safeToString)(this.#state[1])})`;
/* v8 ignore next 2 */
default:
unreachable(this.#state);
}
}
/**
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
@deprecated Use the module-scoped {@linkcode fromUnsafePromise} instead.
*/
static fromUnsafePromise(promise) {
return new exports.Task((resolve, reject) => {
promise.then((0, result_js_1.match)({
Ok: resolve,
Err: reject,
}), (rejectionReason) => {
throw new UnsafePromise(rejectionReason);
});
});
}
/**
Produce a `Task<T, unknown>` from a promise.
To handle the error case and produce a concrete `Task<T, E>` instead, use
the overload which accepts an `onRejection` handler instead.
@param promise The promise from which to create the `Task`.
@group Constructors
@deprecated This will be removed at 9.0. Switch to the module-scoped
function {@linkcode fromPromise}.
*/
static try(promise) {
return new exports.Task((resolve, reject) => {
promise.then(resolve, reject);
});
}
/**
Produce a {@linkcode Task Task<T, E>} from a `Promise<T>` and use a fallback
value if the task rejects, ignoring the rejection reason.
Notes:
- To leave any error as `unknown`, use the overload which accepts only the
promise.
- To handle the rejection reason rather than ignoring it, use the overload
which accepts a function.
@param promise The promise from which to create the `Task`.
@param rejectionValue A function to transform an unknown rejection reason
into a known `E`.
@group Constructors
@deprecated This will be removed at 9.0. Switch to the module-level function
{@linkcode safelyTryOr}, which accepts a callback instead.
*/
static tryOr(promise, rejectionValue) {
return new exports.Task((resolve, reject) => {
promise.then(resolve, (_reason) => reject(rejectionValue));
});
}
/**
Produce a `Task<T, E>` from a `Promise<T>` and a function to transform an
unknown error to `E`.
To leave any error as `unknown`, use the overload which accepts only the
promise.
@param promise The promise from which to create the `Task`.
@param onRejection A function to transform an unknown rejection reason into
a known `E`.
@group Constructors
@deprecated This will be removed at 9.0. Switch to the module-level function
{@linkcode safelyTryOrElse}, which accepts a callback instead.
*/
static tryOrElse(promise, onRejection) {
return new exports.Task((resolve, reject) => {
promise.then(resolve, (reason) => reject(onRejection(reason)));
});
}
// 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_js_1.default : value;
return new exports.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_js_1.default : reason;
return new exports.Task((_, reject) => reject(result));
}
/**
Build a {@linkcode Task Task<T, E>} from a {@linkcode Result Result<T, E>}.
@deprecated Use the module-scoped {@linkcode fromResult} instead.
*/
static fromResult(result) {
return new exports.Task((resolve, reject) => result.match({
Ok: resolve,
Err: reject,
}));
}
/**
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 exports.Task((resolveTask, rejectTask) => {
resolve = resolveTask;
reject = rejectTask;
});
return { task, resolve, reject };
}
get state() {
return this.#state[0];
}
get isPending() {
return this.#state[0] === exports.State.Pending;
}
get isResolved() {
return this.#state[0] === exports.State.Resolved;
}
get isRejected() {
return this.#state[0] === exports.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] === exports.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] === exports.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.resolved(12);
const mappedResolved = aResolvedTask.map(double);
let resolvedResult = await aResolvedTask;
console.log(resolvedResult.toString()); // Ok(24)
const aRejectedTask = Task.rejected("nothing here!");
const mappedRejected = map(double, aRejectedTask);
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 exports.Task.fromUnsafePromise(this.#promise.then((0, result_js_1.map)(mapFn)));
}
/**
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.resolved(12);
const mappedResolved = aResolvedTask.mapErr(extractReason);
console.log(mappedOk)); // Ok(12)
const aRejectedTask = Task.rejected({ code: 101, reason: 'bad file' });
const mappedRejection = await aRejectedTask.map(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 TaskImpl.fromUnsafePromise(this.#promise.then((0, result_js_1.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`.
## Examples
```ts
let resolvedA = Task.resolved<string, string>('A');
let resolvedB = Task.resolved<string, string>('B');
let rejectedA = Task.rejected<string, string>('bad');
let rejectedB = Task.rejected<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")');
```
@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 exports.Task((resolve, reject) => {
this.#promise.then((0, result_js_1.match)({
Ok: (_) => {
other.#promise.then((0, result_js_1.match)({
Ok: resolve,
Err: reject,
}));
},
Err: reject,
}));
});
}
/**
Apply a function to the resulting value if a {@linkcode Task} is {@linkcode
Resolved}, producing a new `Task`; or if it is {@linkcode Rejected} return
the rejection reason unmodified.
This differs from `map` in that `thenFn` returns another `Task`. You can use
`andThen` to combine two functions which *both* create a `Task` from an
unwrapped type.
The [`Promise.prototype.then`][then] method is a helpful comparison: if you
have a `Promise`, you can pass its `then` method a callback which returns
another `Promise`, and the result will not be a *nested* promise, but a
single `Promise`. The difference is that `Promise.prototype.then` unwraps
*all* layers to only ever return a single `Promise` value, whereas this
method will not unwrap nested `Task`s.
`Promise.prototype.then` also acts the same way {@linkcode map
Task.prototype.map} does, while `Task` distinguishes `map` from `andThen`.
> [!NOTE] `andThen` is sometimes also known as `bind`, but *not* aliased as
> such because [`bind` already means something in JavaScript][bind].
[then]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/then
[bind]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/bind
## Examples
```ts
import Task from 'true-myth/task';
const toLengthAsResult = (s: string) => ok(s.length);
const aResolvedTask = Task.resolved('just a string');
const lengthAsResult = await aResolvedTask.andThen(toLengthAsResult);
console.log(lengthAsResult.toString()); // Ok(13)
const aRejectedTask = Task.rejected(['srsly', 'whatever']);
const notLengthAsResult = await aRejectedTask.andThen(toLengthAsResult);
console.log(notLengthAsResult.toString()); // Err(srsly,whatever)
```
@template U The type of the value produced by the new `Task` of the `Result`
returned by the `thenFn`.
@param thenFn The function to apply to the wrapped `T` if `maybe` is `Just`.
*/
andThen(thenFn) {
return new exports.Task((resolve, reject) => {
this.#promise.then((0, result_js_1.match)({
Ok: (value) =>
// 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 consequences of
// the fact that what `Task` is, `Promise` really should be in the
// first place! We have to basically “unwrap” the inner `Result`,
// but to do that, we have to wait for the intermediate `Promise` to
// resolve so that the inner `Result` is available so it can in turn
// be used with the top-most `Task`’s resolution/rejection helpers!
thenFn(value).#promise.then((0, result_js_1.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}.
```ts
import Task from 'true-utils/task';
const resolvedA = Task.resolved<string, string>('a');
const resolvedB = Task.resolved<string, string>('b');
const rejectedWat = Task.rejected<string, string>(':wat:');
const rejectedHeaddesk = Task.rejected<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:")
```
@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 exports.Task((resolve, reject) => {
this.#promise.then((0, result_js_1.match)({
Ok: resolve,
Err: (_) => {
other.#promise.then((0, result_js_1.match)({
Ok: resolve,
Err: reject,
}));
},
}));
});
}
/**
Like {@linkcode or}, but using a function to construct the alternative
{@linkcode Task}.
Sometimes you need to perform an operation using the rejection reason (and
possibly also other data in the environment) to construct a new `Task`,
which may itself resolve or reject. In these situations, you can pass a
function (which may be a closure) as the `elseFn` to generate the fallback
`Task<T, E>`. It can then transform the data in the {@linkcode Rejected} to
something usable as an {@linkcode Resolved}, or generate a new `Rejected`
instance as appropriate.
Useful for transforming failures to usable data, for trigger retries, etc.
@param elseFn The function to apply to the `Rejection` reason if the `Task`
rejects, to create a new `Task`.
*/
orElse(elseFn) {
return new exports.Task((resolve, reject) => {
this.#promise.then((0, result_js_1.match)({
Ok: resolve,
Err: (reason) => {
// See the discussion in `andThen` above; this is exactly the same
// issue, and with inverted implementation logic.
elseFn(reason).#promise.then((0, result_js_1.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((0, result_js_1.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) => exports.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;
}
}
/**
Create a {@linkcode Task} which will resolve to {@linkcode Unit} after a set
interval. (Safely wraps [`setTimeout`][setTimeout].)
[setTimeout]: https://developer.mozilla.org/en-US/docs/Web/API/Window/setTimeout
This can be combined with the {@linkcode Task.timeout} instance method.
@param ms The number of milliseconds to wait before resolving the `Task`.
@returns a Task which resolves to the passed-in number of milliseconds.
*/
function timer(ms) {
return new exports.Task((resolve) => setTimeout(() => resolve(ms), ms));
}
exports.timer = timer;
function all(tasks) {
if (tasks.length === 0) {
return exports.Task.resolve([]);
}
let total = tasks.length;
let oks = Array.from({ length: tasks.length });
let resolved = 0;
let hasRejected = false;
return new exports.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);
}
},
});
}
});
}
exports.all = all;
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 exports.Task((resolve) => {
Promise.all(tasks).then(resolve);
});
}
exports.allSettled = allSettled;
// export function all<A extends readonly AnyTask[]>(tasks: readonly [...A]): All<[...A]>;
function any(tasks) {
if (tasks.length === 0) {
return exports.Task.reject(new AggregateRejection([]));
}
let total = tasks.length;
let hasResolved = false;
let rejections = Array.from({ length: tasks.length });
let rejected = 0;
return new exports.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));
}
},
});
}
});
}
exports.any = any;
function race(tasks) {
if (tasks.length === 0) {
return new exports.Task(() => {
/* pending forever, just like `Promise.race` */
});
}
return new exports.Task((resolve, reject) => {
Promise.race(tasks).then((result) => result.match({
Ok: resolve,
Err: reject,
}));
});
}
exports.race = race;
/**
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.
*/
class AggregateRejection extends Error {
errors;
name = 'AggregateRejection';
constructor(errors) {
super('`Task.race`');
this.errors = errors;
}
toString() {
let internalMessage = this.errors.length > 0 ? `[${(0, utils_js_1.safeToString)(this.errors)}]` : 'No tasks';
return super.toString() + `: ${internalMessage}`;
}
}
exports.AggregateRejection = AggregateRejection;
exports.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
*/
class TaskExecutorException extends Error {
name = 'TrueMyth.Task.ThrowingExecutor';
constructor(originalError) {
super('The executor for `Task` threw an error. This cannot be handled safely.',
// TODO (v9.0): remove this.
// @ts-ignore -- the types for `cause` required `Error | undefined` for a
// while before being loosened to allow `unknown`.
{ cause: originalError });
}
}
exports.TaskExecutorException = TaskExecutorException;
/**
An error thrown when the `Promise<Result<T, E>>` passed to
{@link fromUnsafePromise} rejects.
@group Errors
*/
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}`,
// TODO (v9.0): remove this.
// @ts-ignore -- the types for `cause` required `Error | undefined` for a
// while before being loosened to allow `unknown`.
{ cause: unhandledError });
}
}
exports.UnsafePromise = UnsafePromise;
class InvalidAccess extends Error {
name = 'TrueMyth.Task.InvalidAccess';
constructor(field, state) {
super(`Tried to access 'Task.${field}' when its state was '${state}'`);
}
}
exports.InvalidAccess = InvalidAccess;
/** @inheritdoc Task.tryOr */
exports.tryOr = TaskImpl.tryOr;
/** @inheritdoc Task.tryOrElse */
exports.tryOrElse = TaskImpl.tryOrElse;
/* 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
*/
exports.Task = TaskImpl;
exports.default = exports.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} */
exports.resolve = exports.Task.resolve;
/** Standalone function version of {@linkcode Task.reject} */
exports.reject = exports.Task.reject;
/** Standalone function version of {@linkcode Task.withResolvers} */
exports.withResolvers = exports.Task.withResolvers;
function fromPromise(promise, onRejection) {
let handleError = onRejection ?? identity;
return new exports.Task((resolve, reject) => {
promise.then(resolve, (reason) => reject(handleError(reason)));
});
}
exports.fromPromise = fromPromise;
/**
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
> `Result.tryOr` and `Result.tryOrElse` methods for synchronous functions.
## Examples
Given an `Ok`, `fromResult` will produces a {@linkcode Resolved} 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!"));
```
*/
function fromResult(result) {
return new exports.Task((resolve, reject) => result.match({
Ok: resolve,
Err: reject,
}));
}
exports.fromResult = fromResult;
/**
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
*/
function fromUnsafePromise(promise) {
return new exports.Task((resolve, reject) => {
promise.then((0, result_js_1.match)({
Ok: resolve,
Err: reject,
}), (rejectionReason) => {
throw new UnsafePromise(rejectionReason);
});
});
}
exports.fromUnsafePromise = fromUnsafePromise;
/**
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 Task.try} 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`.
*/
function safelyTry(fn) {
return new exports.Task((resolve, reject) => {
try {
fn().then(resolve, reject);
}
catch (e) {
reject(e);
}
});
}
exports.safelyTry = safelyTry;
function safelyTryOr(rejection, fn) {
const op = (curriedFn) => new exports.Task((resolve, reject) => {
try {
curriedFn().then(resolve, (_reason) => reject(rejection));
}
catch (_e) {
reject(rejection);
}
});
return (0, utils_js_1.curry1)(op, fn);
}
exports.safelyTryOr = safelyTryOr;
function safelyTryOrElse(onError, fn) {
const op = (fn) => new exports.Task((resolve, reject) => {
try {
fn().then(resolve, (reason) => reject(onError(reason)));
}
catch (error) {
reject(onError(error));
}
});
return (0, utils_js_1.curry1)(op, fn);
}
exports.safelyTryOrElse = safelyTryOrElse;
function safe(fn, onError) {
let handleError = onError ?? identity;
return (...params) => safelyTryOrElse(handleError, () => fn(...params));
}
exports.safe = safe;
function safeNullable(fn, onError) {
let handleError = onError ?? identity;
return (...params) => safelyTryOrElse(handleError, async () => {
let theValue = (await fn(...params));
return maybe_js_1.default.of(theValue);
});
}
exports.safeNullable = safeNullable;
function map(mapFn, task) {
return (0, utils_js_1.curry1)((task) => task.map(mapFn), task);
}
exports.map = map;
function mapRejected(mapFn, task) {
return (0, utils_js_1.curry1)((task) => task.mapRejected(mapFn), task);
}
exports.mapRejected = mapRejected;
function and(andTask, task) {
return (0, utils_js_1.curry1)((task) => task.and(andTask), task);
}
exports.and = and;
function andThen(thenFn, task) {
return (0, utils_js_1.curry1)((task) => task.andThen(thenFn), task);
}
exports.andThen = andThen;
function or(other, task) {
return (0, utils_js_1.curry1)((task) => task.or(other), task);
}
exports.or = or;
function orElse(elseFn, task) {
return (0, utils_js_1.curry1)((task) => task.orElse(elseFn), task);
}
exports.orElse = orElse;
function match(matcher, task) {
return (0, utils_js_1.curry1)((task) => task.match(matcher), task);
}
exports.match = match;
function timeout(timerOrMs, task) {
return (0, utils_js_1.curry1)((task) => task.timeout(timerOrMs), task);
}
exports.timeout = timeout;
/**
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.
*/
function toPromise(task) {
return task.toPromise();
}
exports.toPromise = toPromise;
function identity(value) {
return value;
}
/**
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 = withRetries(
() => Task.fromPromise(fetch('https://example.com')).andThen((res) => {
if (res.status === 200) {
return Task.fromPromise(res.cjson());
} 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.researchgate.net/publication/255672213_A_Performance_Comparison_of_Different_Backoff_Algorithms_under_Different_Rebroadcast_Probabilities_for_MANET's
### Manually canceling retries
Sometimes, you may determine that the result of an operation is fatal, so
there is no point in retrying even if the retry strategy still allows it. In
that case, you can return the special `StopRetrying` error produced by calling
`stopRetrying` to immediately stop all further retries.
For example, imagine you have a library function that returns a custom `Error`
subclass that includes an `isFatal` value on it, something like this::
```ts
class AppError extends Error {
isFatal: boolean;
constructor(message: string, options?: { isFatal?: boolean, cause?: unknown }) {
super(message, { cause: options?.cause });
this.isFatal = options?.isFatal ?? false;
}
}
```
You could check that flag in a `Task` rejection and return `stopRetrying()` if
it is set:
```ts
import * as Task from 'true-myth/task';
import { fibonacci, jitter } from 'true-myth/task/delay';
import { doSomethingThatMayFailWithAppError } from 'someplace/in/my-app';
let theTask = Task.withRetries(
() => {
doSomethingThatMayFailWithAppError().orElse((rejection) => {
if (rejection.isFatal) {
return Task.stopRetrying("It was fatal!", { cause: rejection });
}
return Task.reject(rejection);
});
},
fibonacci().map(jitter).take(20)
);
```
### Using the retry `status` parameter
Every time `withRetries` tries the `retryable`, it provides the current count
of attempts and the total elapsed duration as properties on the `status`
object, so you can do different things for a given way of trying the async
operation represented by the `Task` depending on the count. Here, for example,
the task is retried if the HTTP request rejects, with an exponential backoff
starting at 100 milliseconds, and captures the number of retries in an `Error`
wrapping the rejection reason when the response rejects or when converting the
response to JSON fails. It also stops if it has tried the call more than 10
times or if the total elapsed time exceeds 10 seconds.
```ts
import * as Task from 'true-myth/task';
import { exponential, jitter } from 'true-myth/task/delay';
let theResult = await Task.withRetries(
({ count, elapsed }) => {
if (count > 10) {
return Task.stopRetrying(`Tried too many times: ${count}`);
}
if (elapsed > 10_000) {
return Task.stopRetrying(`Took too long: ${elapsed}ms`);
}
retu