neverthrow icon indicating copy to clipboard operation
neverthrow copied to clipboard

Feat request: fromAsync to interop with async functions more easily

Open tmcw opened this issue 1 year ago • 9 comments

Hi! I've been using neverthrow and it's been great! Really enjoying it. I would like to propose 'one more API' 😆 :

The main thing is that we have a bunch of async functions that have known, discrete error types. For example, something that queries the database and if it doesn't find that thing, it returns an error state for the 404. But this means that technically there are two error states - the database query fails (unexpectedly, a 'defect' in Effect terminology), or the thing isn't found (a good ol' error state).

I'd love an API like this, which would let you wrap an async function that returns a Result:

import { ResultAsync, type Result, Err, ok, err } from "neverthrow";

export function fromAsync<A extends readonly any[], IO, IE, E>(
  fn: (...args: A) => Promise<Result<IO, IE | E>>,
  errorFn: (err: unknown) => E
): (...args: A) => ResultAsync<IO, IE | E> {
  return (...args) => {
    return new ResultAsync(
      (async () => {
        try {
          return await fn(...args);
        } catch (error) {
          return new Err(errorFn(error));
        }
      })()
    );
  };
}

// () => ResultAsync<boolean, RangeError | Error>
const a = fromAsync(
  async () => {
    if (Math.random() > 0.5) {
      return err(new RangeError("oh no"));
    }
    // It would be nice to flatten ResultAsync
    // values returned here, but I can't figure this out right now.
    //
    // if (Math.random() > 0.5) {
    //   return ResultAsync.fromPromise(
    //     Promise.resolve(10),
    //     () => new SyntaxError("hi")
    //   );
    // }
    return ok(true);
  },
  () => new Error("hi")
);

It'd also be super nice for this to automatically handle a ResultAsync returned by the async function. This'd give pretty good ergonomics for the common usecase of having an async function with some known error types that you'd like to preserve.

tmcw avatar Nov 04 '24 16:11 tmcw

Hello @tmcw ! Thank you for this proposal.

Could you provide an example of what you resort to doing with the current API ?

paduc avatar Nov 04 '24 16:11 paduc

Sure, here's a (genericized) example from our codebase - in this example and most others, we use Promise<Result> instead of ResultAsync because it's simpler than the alternatives, as far as I can tell.

Mostly because I still want to use async/await, so using an async function makes sense, but async functions always return a promise-wrapped value.

export async function getItem({
  currentUser,
}: {
  currentUser: CurrentUser;
}): Promise<
  Result<
    {
      id: string | null;
    },
    HttpError
  >
> {
  if (!currentUser) {
    return err(httpErrors.unauthorized("Unauthorized"));
  }

  // Check if the currentUser has access to this evaluation Id
  const [item] = await db
    .select({
      id: items.id,
    })
    .from(items);

  if (!item) {
    return err(httpErrors.notFound("Item not found"));
  }


  return ok({
    id: item.id,
  });
}

tmcw avatar Nov 04 '24 17:11 tmcw

hey @tmcw ! but doesn't this approach ruin the whole concept of using Result and ResultAsync? what I mean is that when I (as an external person, contributor, another developer) look at the code and see return type Promise<PutAnyTypeHere> I read it as "potentially can throw an exception", so I need to go inside the method definition and check if I was right or wrong.

on the other hand when I see the return type is Result or ResultAsync I am sure that the function/method can be called without worries to get an unhandled exception or promise rejection. This is how I'd do it with your provided code sample:

function getItem({
  currentUser,
}: {
  currentUser: CurrentUser | null
}): ResultAsync<{ id: string }, HttpError> {
  // Check if the currentUser has access to this evaluation Id
  if (!currentUser) {
    return errAsync(httpErrors.unauthorized('Unauthorized'))
  }

  return ResultAsync.fromThrowable(
    async () => db.select({ id: items.id }).from(items),
    (err) => httpErrors.internal(err),
  )().andThen((foundItems) => {
    if (foundItems.length === 0) {
      return errAsync(httpErrors.notFound('Item not found'))
    }

    const [item] = foundItems
    return okAsync({ id: item.id })
  })
}


async function main(): Promise<void> {
  await getItem({ currentUser: { name: 'John Doe' } }).match(
    ({ id }) => console.log(`Item found with id: ${id}`),
    (error) => console.log(`got an HttpError: ${error.message}`),
  )
}

main()

krawitzzZ avatar Nov 13 '24 21:11 krawitzzZ

Sorry, maybe my example wasn't explicit enough - the fromAsync definition that I laid out will return ResultAsync and would not be able to throw an exception. It calls the given wrapped function from inside of the ResultAsync constructor, and maps any potential errors to a known type.

The main thing that differs here is that it would let me use await. The current pattern of using a non-async function like in your example doesn't work with await - once you call anything asynchronous, you need to switch to chaining with .andThen. In some cases, this means that control flow and variable scopes are going to be trickier to work with than the await syntax, which doesn't require callbacks and chaining. You could shoot yourself in the foot still with this system, but if you do, that rejection is mapped to a known value with the second argument in fromAsync.

tmcw avatar Nov 13 '24 22:11 tmcw

oh, now I see what you mean :) yeah, it's a pity that typescript does not allow us to return custom promise implementation in async functions...

what I did for such cases is wrote a helper module that looks like this

import { Err, Ok, Result, ResultAsync, err, ok } from "neverthrow";

type ResultOrOkValue<T, E> =
  | T
  | Ok<T, E>
  | Err<T, E>
  | Ok<ResultOrOkValue<T, E>, E>;

type EitherResult<T, E> = Result<T, E> | ResultAsync<T, E>;
type EitherResultOrOkValue<T, E> = ResultAsync<T, E> | ResultOrOkValue<T, E>;
type NestedEitherResult<T, E> = EitherResult<EitherResultOrOkValue<T, E>, E>;

type Next = [1, 2, 3, 4, 5, ...never[]];
type InferNestedOkType<T, E, Depth extends number = 0> = Depth extends never
  ? never
  : T extends EitherResult<infer U, E>
  ? InferNestedOkType<U, E, Next[Depth]>
  : T;

export function isResult<T, E>(
  maybeResult: EitherResult<T, E> | T | E
): maybeResult is Result<T, E> {
  return maybeResult instanceof Ok || maybeResult instanceof Err;
}

export function isResultAsync<T, E>(
  maybeResult: EitherResult<T, E> | T | E
): maybeResult is ResultAsync<T, E> {
  return maybeResult instanceof ResultAsync;
}

export function isEitherResult<T, E>(
  maybeResult: EitherResult<T, E> | T | E
): maybeResult is EitherResult<T, E> {
  return isResult(maybeResult) || isResultAsync(maybeResult);
}

function flattenAsync<T, E>(
  result: NestedEitherResult<T, E>
): ResultAsync<InferNestedOkType<T, E>, E> {
  const flattenResults = async (): Promise<
    Result<InferNestedOkType<T, E>, E>
  > => {
    const outerResult = await result;

    if (outerResult.isErr()) {
      return err(outerResult.error);
    }

    let outerValue = outerResult.value;

    while (isEitherResult(outerValue)) {
      // eslint-disable-next-line no-await-in-loop
      const inner = await outerValue;

      if (inner.isErr()) {
        return err(inner.error);
      }

      outerValue = inner.value;
    }

    return ok(outerValue as InferNestedOkType<T, E>);
  };

  return new ResultAsync(flattenResults());
}

export function fromActionAsync<T, E>(
  performAction: () => Promise<NestedEitherResult<T, E>>,
  makeError: (error: unknown) => E
): ResultAsync<InferNestedOkType<T, E>, E> {
  return flattenAsync(ResultAsync.fromPromise(performAction(), makeError));
}

fromActionAsync helps to perform whatever async action you want with defaulting to the E error that can also unwrap nested results, if any (as long as the error type as the same, didn't look into how to handle different kinds of errors yet...)

async function main(): Promise<void> {
  const getResult = (): Result<number, string> => ok(1);
  const getResultAsync = (): ResultAsync<number, string> => okAsync(2);
  const asyncAction = async (): Promise<
    ResultAsync<Result<number, Error>, Error>
  > => {
    const first = getResult();

    if (first.isErr()) {
      return errAsync(new Error(first.error));
    }

    const second = await getResultAsync();

    if (second.isErr()) {
      return errAsync(new Error(second.error));
    }

    return okAsync(ok(1));
  };

  const res = await fromActionAsync(asyncAction, Error);

  if (res.isErr()) {
    console.log(`Error: ${res.error}`);
  } else {
    // typeof res.value  === 'number'
    console.log(`Value: ${res.value}`); // 1
  }
}

main();

krawitzzZ avatar Nov 16 '24 11:11 krawitzzZ

Yep! Roughly the same idea. I've been updating and using our function, and figured out how to make the second argument, the error mapper, option, so that by default you'll get something like an UnexpectedError or a 'defect' in Effect talk:

export function resultFromAsync<
  A extends readonly any[],
  R extends Promise<Result<unknown, unknown>>
>(
  fn: (...args: A) => R
): (
  ...args: A
) => ResultAsync<
  InferOkTypes<Awaited<R>>,
  InferErrTypes<Awaited<R>> | UnexpectedError
>;

export function resultFromAsync<
  A extends readonly any[],
  IO,
  IE,
  E,
  R extends Promise<Result<IO, IE>>
>(
  fn: (...args: A) => R,
  errorFn: (err: unknown) => E
): (
  ...args: A
) => ResultAsync<
  InferOkTypes<Awaited<R>>,
  InferErrTypes<Awaited<R>> | UnexpectedError | E
>;

export function resultFromAsync<
  A extends readonly any[],
  E,
  IO,
  IE,
  R extends Promise<Result<IO, IE>>
>(fn: (...args: A) => R, errorFn?: (err: unknown) => E) {
  return (...args: A) =>
    new ResultAsync<IO, IE | E | UnexpectedError>(
      fn(...args).catch((error) =>
        errorFn
          ? new Err(errorFn(error))
          : new Err(
              new UnexpectedError("Unexpected error (generic)", {
                cause: error,
              })
            )
      )
    );
}

tmcw avatar Dec 09 '24 16:12 tmcw

I've been using the following implementation for a while:

import type { Result, ResultAsync } from 'neverthrow';
import { fromAsyncThrowable } from 'neverthrow';

export const fromPromiseResult =
  <T, E, Args extends unknown[], Fn extends (...args: Args) => Promise<Result<T, E>>>(fn: Fn) =>
  (...args: Args): ResultAsync<T, E> =>
    fromAsyncThrowable(fn, e => e as E)(...args).andThen(x => x);

AFAIK no one has made a PR for this yet. Anyone in this thread feel inclined? Else I will

Harris-Miller avatar Aug 07 '25 11:08 Harris-Miller

I stumbled across this as well recently so I think it's worth a PR @Harris-Miller if you're still willing to consider it.

evanmoran avatar Sep 24 '25 23:09 evanmoran

@evanmoran yes I'll try to get it up this week

Harris-Miller avatar Oct 02 '25 03:10 Harris-Miller