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

How to best emulate Rust's ? operator? #183

Closed
bluenote10 opened this issue Nov 18, 2020 · 6 comments
Closed

How to best emulate Rust's ? operator? #183

bluenote10 opened this issue Nov 18, 2020 · 6 comments

Comments

@bluenote10
Copy link
Contributor

bluenote10 commented Nov 18, 2020

I assume you are familiar with ? operator in Rust? I'm wondering what is the best way to achieve something similar with neverthrow's ResultAsync.

REST APIs often require to execute a chain of requests that depend on each other, i.e., I cannot spawn them independently but need to await a successful result sequentially. In case of an error, I want to translate the error into a different error type. Here's an example:

type ResponseType = {
  some: {
    nested: {
      field: number
    }
  },
  other: {
    nested: {
      field: number
    }
  }
}

async function exampleRequest(arg?: number): Promise<ResponseType> {
  return Promise.resolve({
    some: {nested: {field: 1}},
    other: {nested: {field: 2}},
  });
}

function wrap<T>(promise: Promise<T>): ResultAsync<T, Error> {
  return ResultAsync.fromPromise(promise, (e) => e as Error);
}

async function requestChain(): Promise<Result<number, string>> {

  let aResult = await wrap(exampleRequest())
  if (aResult.isErr()) {
    return err("something went wrong in request A")
  }
  let a = aResult.value.some.nested.field

  let bResult = await wrap(exampleRequest(a))
  if (bResult.isErr()) {
    return err("something went wrong in request B")
  }
  let b = bResult.value.other.nested.field

  let cResult = await wrap(exampleRequest(b))
  if (cResult.isErr()) {
    return err("something went wrong in request C")
  }
  let c = cResult.value.other.nested.field

  return ok(a + b + c)
}

It looks like I'm looking for some kind of unwrapOrReturnError. In Rust such chains simplify a lot when using the ? operator. Any thoughts what would be a nice solution with neverthrow?

I feel simply using map doesn't scale so well, because each request would add a level of indentation (in the specific API case I had a chain of 8 requests). Using andThen seems tricky as well, because the steps have complex interdependencies, e.g. I would need the results of step 3+4 in step 7, but step 1+3 in step 8 or so.

@supermacro
Copy link
Owner

supermacro commented Nov 19, 2020

Unfortunately JS / TypeScript doesn't have an implicit return operator like Rust's ?.

However, by using Result / ResultAsync chains, you are able to have sequential operations that will return early if any Result / ResultAsync is an Err (that's one of the nice features / properties of this lib).

One thing I noticed in your example is that your error types are the same. They're all string. But I see that you're changing the value of the string.

There's a pattern in typescript that allows you to create a sum type whose values have different shapes.

Example:

export type RouteError =
  | { type: 'NotFound'; context?: string }
  | { type: 'Conflict'; context?: string }
  | { type: 'Other'; error?: Error; context?: string, otherRandomField: SomeInterface }
  | { type: 'MissingHeader' }
  | { type: 'InvalidToken' }
  | { type: 'InvalidSession' }
  | { type: 'BadRequest'; context: string }

source

Each value in this type / set can have very different shapes. But they all share the type field. More info here.

This might be helpful for you.

So for your above example, what I would do is as follows:

const exampleRequestA = (): ResultAsync<number, string> =>
  wrap(exampleRequest())
    .mapErr(() => 'something went wrong in request A')
    .map((val) => val.some.nested.field)

const exampleRequestB = (val: number): ResultAsync<number, string> =>
  wrap(exampleRequest(val))
    .mapErr(() => 'something went wrong in request B')
    .map((obj) => val + obj.other.nested.field) // compute intermediary sum

const exampleRequestC = (val: number): ResultAsync<number, string> =>
   wrap(exampleRequest(val))
    .mapErr(() => 'something went wrong in request C')
    .map((obj) => val + obj.other.nested.field) // this would be the final sum

const requestChain = async (): Promise<Result<number, string>> =>
  exampleRequestA()
    .andThen(exampleRequestB)
    .andThen(exampleRequestC)

This may not be the most elegant solution, but it's a solution nonetheless.

I've faced this issue in my side project as well. Here is an example where I have to accumulate values throughout the chain (.map((post) => ({ post, site }))).

@supermacro
Copy link
Owner

supermacro commented Nov 19, 2020

*I unintentionally closed this issue. I'm still in the process of writing a response above ☝️

Ok done 🙂

@bluenote10
Copy link
Contributor Author

bluenote10 commented Nov 19, 2020

Thanks for the inspiration, I'll experiment in this direction. As mentioned, the problem I was facing with andThen was:

Using andThen seems tricky as well, because the steps have complex interdependencies, e.g. I would need the results of step 3+4 in step 7, but step 1+3 in step 8 or so.

My example doesn't capture this well. The dependencies are much more complex then a linear chain of (val: number). Basically each request brings a certain piece of information into scope that is needed by arbitrary steps later on. With andThen I would probably have to "fuse" all the pieces of information together step by step. But it may be awkward to have so many types:

  • input type step 1: ()
  • input type step 2: {a: number}
  • input type step 3: {a: number, b: string}
  • input type step 4: {a: number, b: string, c: Foo}
  • ...

Perhaps I need to combine map + andThen, but use ugly old-school hoisting of a, b, c to avoid having to accumulate everything in a constantly growing type, and instead access these pieces of information from the closure context. For instance:

let a: number
let b: number
let c: number

wrap(exampleRequest()).andThen(result => {
  a = result.some.nested.field
  return wrap(exampleRequest(a))
}).andThen(result => {
  b = result.other.nested.field
  return wrap(exampleRequest(a + b))
}).map(result => {
  c = result.other.nested.field
  return a + b + c
})

@supermacro
Copy link
Owner

supermacro commented Nov 19, 2020

I understand. However, I personally haven't run into a situation that has so many interdependencies that have required me to try and find an alternative to "fusing" things together in a accumulator type. For example, that getSiteComments function I linked to does that "fusing" approach on a collector type. Not the most elegant, but it works for now.


A hypothetical function that could be added to neverthrow would be the following:

type AndThenFunc = (t:  T) => ResultAsync<U, E> | Result<U, E>
ResultAsync<T, E>.andThenCollect<U>(f: AndThenFunc): ResultAsync<[U, T], E> { ... }

It's conceptually the same as andThen, but the previous Results Ok value is "saved" in a tuple. The one issue with this design is that you'd end up with nested tuples (such as [A, [B, [ C, D ] ] ]) if you chained multiple calls to andThenCollect. But there might be a way to overload the function implementation to provide different type definitions so as to have a flat tuple instead of nested tuples irrespective of how many times andThenCollect is called.

The whole point / goal being that you personally wouldn't have to pollute your code with your own version of this glue / fusing code.

Is the above what you had in mind?

@bluenote10
Copy link
Contributor Author

bluenote10 commented Nov 19, 2020

Here is a full demo of the hoisting approach:

async function exampleRequest(arg?: number): Promise<ResponseType> {
  return Promise.resolve(42);
}

type WrappedError = {
  msg: string,
  originalError: Error,
}

function wrapPromise<T>(promise: Promise<T>, msg: string): ResultAsync<T, WrappedError> {
  return ResultAsync.fromPromise(promise, (e) => ({
    msg: msg,
    originalError: e as Error
  }));
}

function startChain(): ResultAsync<null, WrappedError> {
  return okAsync(null)
}

function requestChain(): ResultAsync<number, WrappedError> {

  // predefine stuff that should be remembered across requests
  let a: number
  let b: number
  let c: number

  return startChain().andThen(() => {
    return wrapPromise(
      exampleRequest(),
      "Something failed in request A",
    )
  }).andThen(result => {
    a = result
    return wrapPromise(
      exampleRequest(a),
      "Something failed in request B",
    )
  }).andThen(result => {
    b = result
    return wrapPromise(
      exampleRequest(a + b),
      "Something failed in request C",
    )
  }).andThen(result => {
    c = result
    return ok(a + b + c)
  })
}

A few notes:

  • This feels pretty good in practice despite the ugly hoisting. Practically it gets close to Rust's ? operator in the sense that the function short-circuits on error, all values are naturally unwrapped, and it easily scales to a large number to-be-unwrapped values.
  • Nice that I no longer need any async.
  • The startChain is obviously not necessary, I just liked to make the first request syntactically identical to the others.
  • Similarly the last andThen could also be a plain map returning just the value.
  • I've also extended the example to show what my actual intent is in terms of error handling: In practice I find it useful to have a WrappedError type that allows me to introduce my own error message (which could be exposed to the user) along with storing the original errors (which I would use for debug logging or so).

EDIT: One of your examples inspired me to re-write that using destructuring, which also looks pretty good:

const requestChain: () => ResultAsync<number, WrappedError> = () => (
  startChain().andThen(() =>
    wrapPromise(
      exampleRequest(),
      "Something failed in request A",
    ).map(result => ({
      a: result, // extract `a` here instead
    }))
  ).andThen(({a}) =>
    wrapPromise(
      exampleRequest(a),
      "Something failed in request B",
    ).map(result => ({
      a: a,      // forward
      b: result, // extract `b` here instead
    }))
  ).andThen(({a, b}) =>
    wrapPromise(
      exampleRequest(a + b),
      "Something failed in request C",
    ).map(result => ({
      a: a,      // forward
      b: b,      // forward
      c: result, // extract `c` here instead
    }))
  ).andThen(({a, b, c}) =>
    ok(a + b + c)
  )
)

Is the above what you had in mind?

Yes, exactly. I assume the nested tuple approach would work with a similar destructuring on input side, but would perform the "accumulation" automatically, i.e., it spares the explicit map's in the last example.

(From my perspective the current API allows for a good enough approximation of the ? operator, so feel free to close the issue.)

@supermacro
Copy link
Owner

supermacro commented Nov 20, 2020

I added a new issue (#186) to track the *Collect api's. Not yet sure if it can even be done in a way that allows for flat tuples.

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

2 participants