Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat request: fromAsync to interop with async functions more easily #608

Open
tmcw opened this issue Nov 4, 2024 · 6 comments
Open

Feat request: fromAsync to interop with async functions more easily #608

tmcw opened this issue Nov 4, 2024 · 6 comments

Comments

@tmcw
Copy link

tmcw commented Nov 4, 2024

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 tmcw changed the title fromAsync Feat request: fromAsync to interop with async functions more easily Nov 4, 2024
@paduc
Copy link
Contributor

paduc commented Nov 4, 2024

Hello @tmcw ! Thank you for this proposal.

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

@tmcw
Copy link
Author

tmcw commented Nov 4, 2024

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,
  });
}

@krawitzzZ
Copy link

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()

@tmcw
Copy link
Author

tmcw commented Nov 13, 2024

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.

@krawitzzZ
Copy link

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();

@tmcw
Copy link
Author

tmcw commented Dec 9, 2024

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,
              })
            )
      )
    );
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants