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
JavaScript
/**
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