[![License](https://img.shields.io/github/license/iluha168/rhodium)](https://github.com/iluha168/rhodium)
[![Build status](https://img.shields.io/github/actions/workflow/status/iluha168/rhodium/publish.yml)](https://github.com/iluha168/rhodium/actions/workflows/publish.yml)
[![JSR Version](https://img.shields.io/jsr/v/%40iluha168/rhodium)](https://jsr.io/@iluha168/rhodium)
[![NPM Version](https://img.shields.io/npm/v/rhodium)](https://www.npmjs.com/package/rhodium)
[![NPM Downloads](https://img.shields.io/npm/d18m/rhodium?style=flat&label=npm%20downloads)](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)
  - [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)
    - [`new Rhodium`](#new-rhodium)
      - [`new Rhodium(promiseOrRhodium)`](#new-rhodiumpromiseorrhodium)
      - [`new Rhodium(executor)` - classic `Promise` constructor](#new-rhodiumexecutor---classic-promise-constructor)
  - [Error tracking](#error-tracking)
    - [The `Errored<T>` type](#the-erroredt-type)
- [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()`
- `Rhodium` is **convertible 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
### 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 suppressed. 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 suppressed `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`), and the constructor, are 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.

Example:
```ts
const fetchRh = Rhodium.try(
  (url, signal) => fetch(url, { signal })
)

fetchRh.cancel() // Aborts the network request!
```

### 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`.

> [!TIP]
> The generator function is free to
> - never return;
> - use any JavaScript constructs such as `while`, `for`, `switch`, `if`, `using`, etc.;
> - `yield*` other such generators, including itself;

> [!WARNING]
> But it cannot use `await` (including `await using` and `for await`), because it is not actually an `async` function.

```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!
```

#### `new Rhodium`
There are two ways to use the constructor:

##### `new Rhodium(promiseOrRhodium)`
This is identical to `Rhodium.resolve`, except it **always creates a new instance**. (When the input value is a `Rhodium`, `resolve` simply returns it.)

##### `new Rhodium(executor)` - classic `Promise` constructor
The Rhodium executor has an additional argument, [signal](#early-cancellation). For example, let us convert a pre-Promise-era asynchronous function, that allows cancellation, to a Rhodium:
```ts
new Rhodium((resolve, reject, signal) => {
  try {
    longOperation({
      shouldStop: () => signal.aborted,
      onComplete: resolve,
      onError: reject,
    })
  } catch(e) {
    reject(e)
  }
})
```
> [!TIP]
> If you need to [`cancel`](#cancellation) the new `Rhodium` from inside its executor, you can do so by assigning it to a variable:
> ```ts
> const myNewRhodium = new Rhodium((resolve, reject, signal) => {
>   /* ... */
>   myCancelTrigger.addEventListener('stop',
>     () => myNewRhodium.cancel()
>   )
> })
> ```
> Just make sure you are not attempting to use the variable before the Rhodium was instantiated.

### 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
```

## Inspired by
- [Effect.ts](https://effect.website/)
- [Bluebird](http://bluebirdjs.com/docs/api/cancellation.html)
- [Neverthrow](https://www.npmjs.com/package/neverthrow)
