rhodium
Version:
A TypeScript `Promise` wrapper that adds syntax sugar.
283 lines (238 loc) • 12.6 kB
Markdown
[](https://github.com/iluha168/rhodium)
[](https://github.com/iluha168/rhodium/actions/workflows/publish.yml)
[](https://jsr.io/@iluha168/rhodium)
[](https://www.npmjs.com/package/rhodium)
[](https://www.npmjs.com/package/rhodium?activeTab=versions)
<!-- omit in toc -->
# Rhodium
## About
`Rhodium` is a TypeScript-first `Promise` alternative with error tracking, cancellation, common async utilities, and a sprinkle of syntax sugar.
It uses native `Promise` internally, resulting in minimal performance loss.
`Rhodium` implements all `Promise`'s static and non-static methods, making them cancellable and error-tracking as well.
```ts
import * as Rh from "rhodium" // Static methods
import Rhodium from "rhodium" // Class
```
`Rhodium` depends on nothing but ES2020, and [AbortSignal](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal#browser_compatibility) with [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController#browser_compatibility).
## Table of contents
- [About](#about)
- [Table of contents](#table-of-contents)
- [Interoperability with `Promise`](#interoperability-with-promise)
- [Features](#features)
- [Error tracking](#error-tracking)
- [The `Errored<T>` type](#the-erroredt-type)
- [Cancellation](#cancellation)
- [Limitations](#limitations)
- [But what about multiple `then` calls on a single Rhodium?](#but-what-about-multiple-then-calls-on-a-single-rhodium)
- [Finalization](#finalization)
- [`Rhodium.oneFinalized`](#rhodiumonefinalized)
- [Early cancellation](#early-cancellation)
- [Additional methods \& syntax sugar](#additional-methods--syntax-sugar)
- [`Rhodium.sleep`](#rhodiumsleep)
- [`Rhodium.oneSettled`](#rhodiumonesettled)
- [`Rhodium.tryGen` - the `async` of Rhodium](#rhodiumtrygen---the-async-of-rhodium)
- [`Rhodium.catchFilter`](#rhodiumcatchfilter)
- [`Rhodium.timeout`](#rhodiumtimeout)
- [Inspired by](#inspired-by)
## Interoperability with `Promise`
- `Rhodium` **is awaitable** at runtime, and `Awaited<T>` can be used to await it in types.
- `Promise`, or any `PromiseLike`, **can be converted to `Rhodium`** by passing it to `Rhodium.resolve()` or `new Rhodium()`
- Is **convertable to `Promise`**. Simply get the **`promise` property** of a `Rhodium` instance.
> [!NOTE]
> Conversion to `Promise` loses [cancelability](#cancellation) and other `Rhodium`-exclusive [features](#features).
## Features
### Error tracking
Rhodium keeps track of all errors a `Rhodium` chain may reject with, if used correctly.
```ts
Rhodium
.try(
chance => chance > 0.5
? "success"
: Rhodium.reject("error"),
Math.random(),
)
.then(data => data /* <? "success" */)
.catch(e => e /* <? "error" */ )
.then(data => data /* <? "success" | "error" */)
```
> [!CAUTION]
> This library assumes `throw` keyword is never used. **It is impossible to track types of `throw` errors.** `Rhodium` has a neverthrow philosophy; you must always use `Rhodium.reject()` instead. I suggest enforcing this rule if you decide to adopt `Rhodium`.
> [!CAUTION]
> All errors must be structurally distinct:
> - ❌ `new SyntaxError` ≈ `new TypeError`
> - ✔️ `class ErrA { code = 1 as const }` ≉ `class ErrB { code = 2 as const }`
>
> This is a TypeScript limitation. Any object containing another triggers a subtype reduction. Usually this object would be constructed by `Rhodium.reject()`, but this does work for everything, e.g., arrays:
> ```ts
> class ErrorA extends Error {}
> class ErrorB extends Error {}
> // ▼? const result: ErrorA[]
> const result = Math.random() > 0.5
> ? [new ErrorA()]
> : [new ErrorB()]
> ```
> [!IMPORTANT]
> Other **`PromiseLike`** objects returned inside the chain automatically change the **error type to `unknown`**. We can never be sure what type they reject, if any. This includes **`async` callbacks**, as they always return `Promise`s.
#### The `Errored<T>` type
Similarly to `Awaited<T>`, which returns the resolution type,
`Errored<T>` returns the error type.
```ts
const promise = Promise.reject()
type E = Errored<typeof promise>
// ^? unknown
```
```ts
const promise = Rhodium.resolve()
type E = Errored<typeof promise>
// ^? never
```
```ts
const promise = Rhodium.reject()
type E = Errored<typeof promise>
// ^? void
```
```ts
const promise = Rhodium.reject(new TypeError())
type E = Errored<typeof promise>
// ^? TypeError
```
### Cancellation
Cancellation prevents any further callbacks from running.
> [!IMPORTANT]
> `finally` is completely unaffected by the cancellation feature. This callback will always execute, and it can be attached to a cancelled `Rhodium`.
Here is an example:
```ts
const myRhodium = Rhodium
.try(() => console.log(1))
.then(() => Rhodium.sleep(1000))
.then(() => console.log(2))
.finally(() => console.log(3))
```
This should print `1`, `2` and `3`, right? And it does!
However, if we append this line:
```ts
setTimeout(() => myRhodium.cancel(), 500)
```
...then suddenly only `1` and `3` are printed. Invocation of `cancel` has prevented the second `console.log`!
> [!IMPORTANT]
> - `cancel` [returns a `Rhodium`](#rhodiumonefinalized), but it is actually **synchronous** at its core. Once `cancel` is run, its effects are immediate.
> - If `Rhodium` rejects right before cancellation, the reason might get supressed. If `Rhodium` rejects during cancellation, the reason gets caught into [the returned value](#rhodiumonefinalized).
In addition, [the described below limitation](#limitations) causes `cancel` to return a rejecting `Rhodium`, to preserve the [ease of handling errors](#error-tracking).
#### Limitations
> [!WARNING]
> - Only the last `Rhodium` in a given chain can be cancelled. Cancelling in the middle is not allowed.
> - It is impossible to attach a new callback to a cancelled `Rhodium`, because no callbacks would run off of it. This situation **throws synchronously**, as deemed unintentional by the programmer. A check using `cancelled` property is possible, if that is the intended behaviour.
#### But what about multiple `then` calls on a single Rhodium?
A `Rhodium` or a `Promise` chain is not really a chain - it is a tree. What happens to the other branches when one gets cut off?
In that case, a branch gets cancelled all the way up until it meets another branch. Suppose you have a following tree of `Rhodium`s:
```
A -> B -> C -> D
\> E -> F -> G
```
Only `D` and `G` would be cancellable, because they are at the ends of their chains. There are 3 possibilities to consider:
- When `D` gets cancelled, so does `C`.
- When `G` gets cancelled, so do `F` and then `E`.
- **Only when both** `D` and `G` get cancelled, no matter the order, do `B` and `A` get cancelled as well.
### Finalization
#### `Rhodium.oneFinalized`
*Also known as the resolution value of `cancel`.*
Has a non-static shorthand called `Rhodium.finalized`.
The returned `Rhodium` is **resolved** once
- `this` `Rhodium` had [settled](#rhodiumonesettled), or
- the **currently running callback** and all of the following **`finally` callbacks** of the cancelled chain had been executed.
```ts
Rhodium
.sleep(100)
.cancel()
.then(finalizationResult => /* == { status: "cancelled" } */)
// ^? RhodiumFinalizedResult<void, never>
```
> [!TIP]
> Awaiting finalization could be useful, for example, to
> - suspense starting new chains, that use the same non-shareable resource, held by the cancelling chain;
> - check for supressed `cancel` errors, which one might want to rethrow;
> - etc.
> [!NOTE]
> The returned `Rhodium` is the beginning of a new chain. It is detached from the input `Rhodium`, and will not propagate cancellation to it.
#### Early cancellation
On `cancel`, the currently running callback will not be stopped, and it will delay finalization. If the time it takes for a Rhodium to finalize is important, then it might be of interest to optimize this time.
Every callback, attached by `then`, `catch`, etc. (except the non-cancellable `finally`) is provided an **`AbortSignal`** as the second argument, which is triggered when that callback has been running at the time of `cancel`. On signal, the callback should resolve as soon as possible.
Using this signal is completely optional - it is only an optimization.
### Additional methods & syntax sugar
#### `Rhodium.sleep`
You no longer have to write the following boilerplate:
```ts
new Promise(resolve => setTimeout(resolve, milliseconds))
```
The same can now be written as `Rhodium.sleep(milliseconds)`, with the advantage of being [early cancellable](#early-cancellation).
> [!NOTE]
> `sleep` uses [`AbortSignal.timeout`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static) internally!
> > The timeout is based on active rather than elapsed time, and will effectively be paused if the code is running in a suspended worker, or while the document is in a back-forward cache.
#### `Rhodium.oneSettled`
Same as `Promise.allSettled()`; except the rejection reason is properly typed, and it is applied to one `Rhodium` instead of an array.
Has a non-static shorthand called `Rhodium.settled`.
A settled `Rhodium` can be safely `await`ed! You will not lose the error type, because it makes its way into the resolution type.
However, `async` functions still have rejection type `unknown`, and cannot be cancelled - for that reason use [`Rhodium.tryGen`](#rhodiumtrygen---the-async-of-rhodium).
```ts
const myRhodium: Rhodium<"value", Error> = /* ... */
const { value, reason, status } = await myRhodium.settled()
// ^? value: "value" | undefined
// reason: Error | undefined
// status: "fulfilled" | "rejected"
if (value) {
console.log(value, reason, status)
// ^? value: "value"
// reason: undefined
// status: "fulfilled"
} else {
console.log(value, reason, status)
// ^? value: undefined
// reason: Error
// status: "rejected"
}
```
#### `Rhodium.tryGen` - the `async` of Rhodium
Executes a generator function, that is now able to type-safely `await` Rhodiums, by `yield*`-ing them instead. When a yielded `Rhodium` resolves, the generator is resumed with that resolution value.
The return value of the generator becomes the resolution value of `tryGen`.
> [!CAUTION]
> The generator function is free to
> - never exit;
> - use any JavaScript constructs such as `while`, `for`, `switch`, `if`, etc.;
> - `yield*` other such generators, including itself;
>
> **but it cannot utilize `try {} catch {}`**. The `catch` block will never be executed, as a part of [the neverthrow philosophy](#error-tracking). Any unhandled rejection will move the generator's execution to the `finally` block, if there is one, and after it completes, `tryGen` will reject.
```ts
Rhodium.tryGen(function* () {
for (let i = 1; i <= 10; i++) {
const { value: items, reason } = yield* fetchItems(i).settled()
if (items) {
console.log(`Page ${i}: ${items}`)
} else {
console.error(reason)
}
yield* Rhodium.sleep(1000)
}
})
```
#### `Rhodium.catchFilter`
With the power of type guards, handling specific errors becomes easy. The first argument is a filter for specific errors, and the second is the callback, which gets called with only the allowed errors. Filtered out errors get rejected again, unaffected, essentially "skipping" `catchFilter`.
```ts
const myRhodium: Rhodium<Data, ErrorA | ErrorB> = /* ... */
myRhodium.catchFilter(
err => err instanceof ErrorA,
(err /* : ErrorA */) => "handled ErrorA" as const
) // <? Rhodium<Data | "handled ErrorA", ErrorB>
```
#### `Rhodium.timeout`
Attaches a time constraint to a Rhodium.
If it fails to [settle](#rhodiumonesettled) in the given time, chains after `timeout` get a rejection, and chain before `timeout` gets cancelled.
```ts
Rhodium
.sleep(10000)
.timeout(10) // Uh oh, this rejects, sleep takes too long
.finalized() // Resolves quickly, because sleep is cancelled!
```
## Inspired by
- [Effect.ts](https://effect.website/)
- [Bluebird](http://bluebirdjs.com/docs/api/cancellation.html)
- [Neverthrow](https://www.npmjs.com/package/neverthrow)