supermacro/neverthrow

Why does `ResultAsync` implement `PromiseLike` instead of `Promise`?

Josef37 opened this issue ยท 2 comments

This is a minor nit-pick that would allow me to simplify/unify some code ๐Ÿค“

Problem description

ResultAsync is thenable meaning it behaves exactly like a native Promise<Result> ...

But using ResultAsync.then doesn't return a Promise which in turn doesn't even allow me to create a ResultAsync from it...

const result = new ResultAsync(okAsync({}).then(x => x));

This fails with Argument of type 'PromiseLike<unknown>' is not assignable to parameter of type 'Promise<Result<unknown, unknown>>'.

In some cases functions are easier to write using await resulting in a Promise<Result> and sometimes ResultAsync is nicer.

Why is that important?

Is isn't really... It's just a nice-to-have. I'm fine with type-casting in a few places.

Code where problem occurred

I have written quite a black-magic function to simplify chaining functions which depend on results of previous steps in which I'd like to treat Promise<Result> and ResultAsync equally.

The comments should explain most of it:

/**
 * Merges provided `args` with the success result of the `callback` allowing to easily chain callbacks and use results for earlier steps.
 *
 * Example:
 * ```ts
 * const first = preserveArgs(() => okAsync({ one: 2 }));
 * const second = preserveArgs(() => okAsync({ two: 3 }));
 * const third = preserveArgs(({ one, two }) => okAsync({ three: one + two }));
 *
 * const result = okAsync({})
 *  .andThen(first)  // `{}` -> `{ one: 2 }`
 *  .andThen(second) // `{ one: 2 }` -> `{ one: 2, two: 3 }`
 *  .andThen(third); // `{ one: 2, two: 3 }` -> `{ one: 2, two: 3, three: 5 }`
 * ```
 */
export const preserveArgs = <CallbackArgs, Ok, Err>(callback: (args: CallbackArgs) => Promise<Result<Ok, Err>> | ResultAsync<Ok, Err>) =>
  <Args extends CallbackArgs>(args: Args) => {
    const asyncResult = callback(args);
    const promise = asyncResult.then((result) =>
      result.map((success) => ({ ...args, ...success })),
    );
    // We need to type-cast here, because `ResultAsync.then` returns a `PromiseLike` object,
    // but expects a `Promise` object in the constructor.
    // In reality, `ResultAsync.then` always returns a `Promise` object.
    return new ResultAsync(promise as Promise<Result<Args & Ok, Err>>);
  };

Formulating the problem helped me figure out a nice solution for my use-case.
Still, I'll leave the issue up for discussion ๐Ÿ˜‡

type AnyResult<Ok, Err> =
  | Result<Ok, Err>
  | Promise<Result<Ok, Err>>
  | ResultAsync<Ok, Err>;

const makeResultAsync = <Ok, Err>(result: AnyResult<Ok, Err>) =>
  result instanceof ResultAsync
    ? result
    : new ResultAsync(Promise.resolve(result));

/** ... */
export const preserveArgs = <CallbackArgs, Ok, Err>(callback: (args: CallbackArgs) => AnyResult<Ok, Err>) =>
  <Args extends CallbackArgs>(args: Args) => {
    const result = callback(args);
    const resultAsync = makeResultAsync(result);
    return resultAsync.map((success) => ({ ...args, ...success }));
  };

Hmmm, interesting. It was overlooked to update the definition of the ResultAsync constructor to accept a PromiseLike rather than a promise. That being said, I don't see myself pushing this change as it has downstream implications that I don't think justify implementing this change:

- constructor(res: Promise<Result<T, E>>) {
+ constructor(res: PromiseLike<Result<T, E>>) {
    this._promise = res
  }

That being said, it's not advised to use the constructor - but rather use the from* apis:

  • .fromPromise
  • .fromSafePromise

Example:

image

Here's your code using the ideomatic fromSafePromise / fromPromise API:

export const preserveArgs = <CallbackArgs, Ok, Err>(callback: (args: CallbackArgs) => Promise<Result<Ok, Err>> | ResultAsync<Ok, Err>) =>
  <Args extends CallbackArgs>(args: Args) => {
    const asyncResult = callback(args);
    const promise = asyncResult.then((result) =>
      result.map((success) => ({ ...args, ...success })),
    );

    // Ideomatic `neverthrow` usage :)
    //   ResultAsync.fromSafePromise(promise);
    //   ResultAsync.fromPromise(promise, errorHandlerFn);
    //
    return ResultAsync.fromSafePromise(promise);
  };

Some context

To answer your original question, "Why does ResultAsync implement PromiseLike instead of Promise?". The reason is that this is the protocol in order to make things "thenable".

By indicating ResultAsync<T, E> implements PromiseLike<Result<T, E>> {} we are forced to then define how .then / async / await behaviour works for a ResultAsync.

https://github.com/supermacro/neverthrow/blob/master/src/result-async.ts#L141

Conclusion / Summary

Please do not use new ResultAsync, instead use the ResultAsync.fromPromise & ResultAsync.fromSafePromise apis