traverse1984 / oxide.ts

Rust's Option<T> and Result<T, E>, implemented for TypeScript.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Async API

sidiousvic opened this issue · comments

It would be nice if there were more utilities to work with asynchronous functions, such as an idiomatic way to wrap a Promise with a ResultLike object to allow chaining and unwrapping of async values.

As an example, neverthrow offers a ResultAsync class which is useful for getting out of Result<Promise<Result<T, E>>, E> hell.

Consider the following example in oxide.ts:

      const asyncComputation = async (n: number) => (n > 1 ? 0 : n);

      const firstComputation: Result<number, null> = Result(1);

      const secondComputation: Result<Promise<Result<number, Error>>, null> = firstComputation.map(
          (n) => Result.safe(asyncComputation(n))
      );

      const thirdComputation: Result<Promise<Result<string, Error>>, null> = secondComputation.map(
          async (secondComputationPromise) => (await secondComputationPromise).map((n) => n.toString())
      );

With neverthrow's Asynchronous API:

      const asyncComputation = async (n: number) => (n > 1 ? 0 : n);

      const firstComputation: Result<number, null> = new Ok(1);

      const secondComputation: Result<ResultAsync<number, Error>, null> = firstComputation.map(
          (n) => ResultAsync.fromPromise(asyncComputation(n), () => new Error())
      );

      const thirdComputation: Result<string, Error | null> = await secondComputation.asyncAndThen(
          (secondComputationPromise) => secondComputationPromise.map((n) => n.toString())
      );

Perhaps I am missing a way to do it more simply in oxide.ts. If not, would this be a useful adventure to embark on?

The neverthrow API definitely handles this better here. Personally, Im not certain I like the look of either of them from a readability perspective.

What I definitely don't want to do is just "copy" the neverthrow async API. Unless I, you, or someone else has some inspiration for how to make a nice async API that we could - hand on heart - say improves upon neverthrow's, I'd rather keep the package lighter.

So with that in mind - did you have any ideas?

This is just a rough draft. After a little testing with an actual prod use case I think it works rather as I expect, but it might have footguns I haven't seen. Happy to hear your thoughts.

The aim is to be able to apply an async function to a result's wrapped value. Scenarios:

Given R is Result<T, E>
Given A is (S) => Promise<U>
Given F is (unknown) => G

  • Given R.isOk, apply A to T.

    • Given that A(T) resolves with S, return Promise<Ok<U>> Caller awaits and gets Ok<U>.
    • Given that A(T) rejects with unknown, return Promise<Err<G>>. Caller awaits and gets Err<G>.
  • Given R.isErr, return Promise<Err<E>>. Caller awaits and gets Err<E>.

export class ResultType<T, E> {
...
  async mapAsync<S, F>(
      map: (val: T) => Promise<S>,
      mapErr: (val: unknown) => F
   ): Promise<Result<S, E | F>> {
      return this[T]
         ? await map(this[Val] as T).then(
              (val) => Ok(val),
              (err: unknown) => Err(mapErr(err))
           )
         : Err(this[Val] as E);
   }

Usage:

  it("mapAsync", async () => {
      const happyResult = Ok(1);
      const sadResult = Err("Boo");

      const happyThenResolves = await happyResult.mapAsync(
         (val: number) => Promise.resolve(val),
         (err: unknown) => "There was an error."
      );

      const happyThenRejects = await happyResult.mapAsync(
         (val: number) => Promise.reject(),
         (err: unknown) => "There was an error with the promise."
      );

      const sad = await sadResult.mapAsync(
         () => Promise.resolve(1),
         (err: unknown) => "There was an error with the result."
      );

      expect(happyThenResolves.unwrap()).to.equal(1);
      expect(happyThenRejects.unwrapErr()).to.equal(
         "There was an error with the promise."
      );
      expect(sad.unwrapErr()).to.equal("Boo");
   });

+1 for keeping the package lighter if the above does not approach something useful in general.

Assuming it does not, could I ask your opinion about how you have handled async scenarios like this with oxide as it is? I fear I might be overlooking an existing solution.

So I think your method is certainly useful, though I'm not not jumping the gun on any solution just yet. The question is how many methods should we add. Just this one? Replicate more of the API for async use cases?

Once you have converted to a Promise, the game is up - it devalues adding lots of new methods if they all essentially are convert/map this to a promise, without ergonomic chaining (like neverthrow). At that point, we get back to the first point about 'not duplicating the neverthrow API'.

One idea I have been toying with is to add the capability to turn Result<Promise<T>, E> into a promise, though I can't decide on the most desirable output. Currently I've been looking at Result<Promise<T>, E> into Promise<Result<T, E | Error>>.

class ResultType<T, E> {
   ...
   get awaitable(): T extends Promise<infer U>
      ? Promise<Result<U, E | Error>>
      : Promise<Result<T, E>> {
      if (this[T] && this[Val] instanceof Promise) {
         return Result.safe(this[Val]) as any;
      }

      return Promise.resolve(this) as any;
   }
}

And comparing to mapAsync:

const x: Result<number, Error | null> = await firstComputation.mapAsync(asyncComputation, (e) =>
   e instanceof Error ? e : new Error("Unexpected throw type")
);

const x: Result<number, Error | null> = await firstComputation.map(asyncComputation).awaitable;

Also considered whether a nested result should be preferred:

const x: Result<Result<number, null>, Error> = something.awaitable;
const y: Result<number, Error | null> = x.flatten(); // To get back to the same type as above

This might be better because you can more clearly discern between a promise rejection and a promise resolution which yields an error. But on the flip side, it might just mean loads of calls to flatten() everywhere.

The question is how many methods should we add. Just this one? Replicate more of the API for async use cases?
Once you have converted to a Promise, the game is up - it devalues adding lots of new methods if they all essentially are convert/map this to a promise, without ergonomic chaining (like neverthrow). At that point, we get back to the first point about 'not duplicating the neverthrow API'.

I see your point about mapAsync being like a gateway drug to commissioning a full-fledged async API.

I really like the awaitable idea, and it goes well with the mapAsync usecase for mapping the rejected value. For example:

const nextResult = (await result.map(asyncFn).awaitable).mapErr(errMappingFn);

With both mapAsync and awaitable, I think the fact that they push you outside of the Result chain is semantically useful. After all, an asynchronous instruction is impure. I prefer it from a pragmatic point of view too, vs. having to flatten awaitables.

So that's a +1 for awaitable: Result<T, E | F>.

+1 for the awaitable: Result<T, E | F>. The API is a lot more ergonomic than calling flatten()s.

Maybe in this particular case it would be useful to discuss a new Task type.

Maybe in this particular case it would be useful to discuss a new Task type.

Did you have an idea how such a type might work?

Maybe in this particular case it would be useful to discuss a new Task type.

Did you have an idea how such a type might work?

I'd look for inspiration in fun-task and fluture

I think I've decided that I don't want to create an async API, at least right now. What I do want to do is improve the interoperability of promises in some situations. I've mentioned a couple in #15 but very happy to hear any further ideas or feedback.

I don't have a great idea for this, but I am interested in using oxide in a point free style. Neverthrow's api is clunky, but allows for that.

Dealing with a single promise in a point free style isn't too bad, but adding one more to a call chain gets ugly. I'm playing with the library for the first time and I'm dealing with a lot of Result<Option<T>, E> and Promise<Result<Option<T>, E>> types. It would be lovely to be able to map and flatten with promises in the mix.