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

Proposal: a solution for emulating Rust's ? operator #444

Closed
tsuburin opened this issue Jan 8, 2023 · 4 comments · Fixed by #448
Closed

Proposal: a solution for emulating Rust's ? operator #444

tsuburin opened this issue Jan 8, 2023 · 4 comments · Fixed by #448

Comments

@tsuburin
Copy link
Contributor

tsuburin commented Jan 8, 2023

In short

The following code emulates the ? operator:

// `returnError` and `block` are utility functions for emulation
function returnError<T, E>(result: Result<T, E>): Generator<Err<never, E>, T> {
  return function*() {
    if (result.isOk()) {
      return result.value
    }
    yield err(result.error) // Calling err(result.error) for changing the type from Err<T, E> to Err<never, E>

    // As long as being consumed by `yield*` in `block`,
    // `next()` of this generator is called just once, so this should never happen
    throw new Error("THIS SHOULD NEVER HAPPEN")
  }()
}

function block<T, E>(
  body: () => Generator<Err<never, E>, Result<T, E>>
) {
  return body().next().value
}


// Example usages

// type of x is Result<"foo", "bar">, and its value is ok("foo")
const x = block<"foo", "bar">(
  function*() {
    const r: Result<"baz", "bar"> = ok("baz")
    const okBaz = yield* returnError(r) // here the type of okBaz is "baz", and the value on execution time is so, too
    return ok("foo") // and finally returns this value
  }
)

// type of y is Result<"foo", "bar">, and its value is err("bar")
const y = block<"foo", "bar">(
  function*() {
    const r: Result<"baz", "bar"> = err("bar")
    const okBaz = yield* returnError(r) // the type of okBaz is "baz", but this block returns err("bar") here
    return ok("foo") // and this line is not evaluated
  }
)

Of course you can use yield*s and returns as many as you want in a block.

Description

An expression yield* gen in a generator function, here gen is a generator, yields what are yielded in gen, and is evaluated to what is returned in gen, without yielding the returned value. (Please see MDN)
As returnError is defined to return a generator that yields the argument if the argument is error and otherwise returns its unwrapped (ok's) value, yield* returnError(r) yields r if r is error, and otherwise is evaluated to r.value without yielding anything.
Finally, as block is defined to call next() on the argument exactly once and return that value, block(function*(){STATEMENTS}) is evaluated to the first occurrence in STATEMENTS of either yield* returnError(some_error) or return some_result.

More integrated version (including async support)

It makes no sense to import returnError or block independently, so I combined them into single object block.
async block is also implemented by overload. It can be used as its type implies.

export const block:
  & (<T, E>(
    body: () => Generator<Err<never, E>, Result<T, E>>
  ) => Result<T, E>)
  & (<T, E>(
    body: () => AsyncGenerator<Err<never, E>, Result<T, E>>
  ) => Promise<Result<T, E>>)
  & {
    returnError: <T, E>(result: Result<T, E>) => Generator<Err<never, E>, T>
  }
  = (() => {
    function fBlock<T, E>(
      body: () => Generator<Err<never, E>, Result<T, E>>
    ): Result<T, E>
    function fBlock<T, E>(
      body: () => AsyncGenerator<Err<never, E>, Result<T, E>>
    ): Promise<Result<T, E>>
    function fBlock<T, E>(
      body:
        | (() => Generator<Err<never, E>, Result<T, E>>)
        | (() => AsyncGenerator<Err<never, E>, Result<T, E>>)
    ) {
      const n = body().next()
      if (n instanceof Promise) {
        return n.then(r => r.value)
      }
      return n.value
    }

    return Object.assign(
      fBlock,
      {
        returnError: <T, E>(result: Result<T, E>): Generator<Err<never, E>, T> => {
          return function*() {
            if (result.isOk()) {
              return result.value
            }
            yield err(result.error)
            throw new Error("THIS SHOULD NEVER HAPPEN")
          }()
        }
      },
    )
  })()

Question

Should I make a PR? I'm willing to make a PR if this is useful for this project.
If so, do anyone have any opinion how to integrate this to the existing APIs? (naming, module structure, or anything else)

Of course, any comments would be appreciated.

Related issues

#183

@Bistard
Copy link

Bistard commented Sep 10, 2023

I am not sure if I am following your steps: In your solution, as a programmer, do you mean we always need two functions (returnError and block) to simulate the behavior of the ? in Rust?

Not just it, the programmer also needs to be aware of the usage of yield * before every returnError.

I think this is too overhead for daily programming - It makes code messy.

@Bistard
Copy link

Bistard commented Sep 10, 2023

I actually did have a thought on how to bring ? into the TypeScript. I haven't implemented it but I will show it in this discussion. Here is how I do it (quite simple):

Step 1- New API questionMark

interface IResult<T, E> {
    // ...

    /**
     * @description Returns inner data when the result is Ok instance.
     * @throws Will throw when the result is an Err instance.
     */
    questionMark(): T;

    // ...
}

class Ok<T> implements IResult<T, never> {

    // ...

    public questionMark(): T {
        return this._data;
    }
}

class Err<E> implements IResult<never, E> {

    constructor(public readonly _data: E) {}

    // ...

    public questionMark(): never {
        throw new QuestionMarkError(this._data);
    }
}

class QuestionMarkError<E> extends Error {

    constructor(public readonly data: E) {
        super();
    }
}

Step 2 - Motivations

Before I goes one step further, imagine the following case:

function someFunction(): IResult<string, Error> {
    
    const res: IResult<string, Error> = readFile();
    
    /**
     * I want control flow to stop here when the result instance is Err.
     * Continue the control flow when the result instance is Ok.
     */
    const str: string = res.questionMark();

    console.log(str);

    return new Ok(str);
}

How do we change the control flow? In JavaScript, there aren't much of ways that we can change the control flow. However, we can use the throw keyword to force the program to stop at that point (when questionMark is invoked).

But not just it, we need the someFunction to work properly - meaning the function itself should never throw and always return an IResult<string, Error>.

Step 3 - Decorators

We can decorators to do it:

function neverThrow<Fn extends (...args: any[]) => any>(target: Fn): Fn {
    return (function (this: any, ...args: any[]): any {
        try {
            return target.apply(this, args);
        } catch (err) {
            // we only expect `QuestionMarkError`
            if (err instanceof QuestionMarkError) {
                return new Err(err.data);
            } 
            // rethrow other unexpected error
            throw err;
        }
    }) as Fn;
}

Two Cases

// case1

function readFile(): IResult<string, Error {
    return new Err('readFile failed');
}

function someFunction(): IResult<string, Error> {
    const res: IResult<string, Error> = readFile();
    const str: string = res.questionMark();

    console.log(str);

    return new Ok(str);
}

try {
    someFunction();
} catch (err) {
    console.log('case1 (an error got thrown):', err);
}

// case 2

const someFunction2 = neverThrow(function (): IResult<string, Error> {
    const res: IResult<string, Error> = readFile();
    const str: string = res.questionMark();

    console.log(str);

    return new Ok(str);
});

const res2 = someFunction2();
console.log('case2 (Err is returned):', res2);

@Bistard
Copy link

Bistard commented Sep 10, 2023

The above is still not perfect - it requires the usage of the neverThrow decorator as a utility. That is why I stoped at this point and thought about the limitations of TypeScript may never achieve such a thing elegantly.

@tsuburin
Copy link
Contributor Author

@Bistard Thank you for your comments.

In your solution, as a programmer, do you mean we always need two functions (returnError and block) to simulate the behavior of the ? in Rust?

Yes.

Not just it, the programmer also needs to be aware of the usage of yield * before every returnError.

I think this is too overhead for daily programming - It makes code messy.

I admit that this is not an elegant solution. However, in my opinion, this makes early-return pattern less messy.

The thing is about the control flow, so if we pursue a perfect solution, eventually we will need to introduce a new syntax to javascript. This might be interesting, but is far beyond the scope of this repository.

Comment on #444 (comment) :
As you said, throw can be used to change the control flow. At the beginning, I had the very same idea as you wrote. The problem was that throw can't convey error type. So I finally adopted generators.

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

Successfully merging a pull request may close this issue.

2 participants