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

Early return / try! / ? operator #43

Open
dbrgn opened this issue Nov 26, 2021 · 3 comments
Open

Early return / try! / ? operator #43

dbrgn opened this issue Nov 26, 2021 · 3 comments

Comments

@dbrgn
Copy link

dbrgn commented Nov 26, 2021

This library seems quite nice, but it has a main downside compared to Rust code: The lack of early-return on errors.

Take this as an example:

async fn read(path: string, key: string) -> Promise<Result<Something, 'not-found' | 'not-readable' | 'invalid'>> {
    // Validate
    if (!fs.existsSync(path)) {
        return new Err('not-found');
    }

    // Read
    let fileContents: Buffer;
    try {
        fileContents = await fsPromises.readFile(path);
    } catch (e) {
        log.warn(`Cannot read file: ${e}`);
        return new Err('not-readable');
    }

    // Decrypt
    try {
        let decrypted = await cryptolib.decrypt(fileContents, key);
    } catch (e) {
        return new Err('invalid');
    }
    return new Ok(decrypted);
}

This is quite nice and it shows exactly what can go wrong, in a typesafe way.

However, if I want to refactor this and break it up into three functions:

type ReadError = 'not-found' | 'not-readable' | 'invalid';

fn validatePath(path: string) -> Result<string, ReadError> {
    if (!fs.existsSync(path)) {
        return new Err('not-found');
    }
    return new Ok(path);
}

async fn readBytes(path: string) -> Promise<Result<Buffer, ReadError>> {
    let fileContents: Buffer;
    try {
        fileContents = await fsPromises.readFile(path);
    } catch (e) {
        log.warn(`Cannot read file: ${e}`);
        return new Err('not-readable');
    }
    return new Ok(fileContents);
}

async fn decrypt(fileContents: Buffer, key: string) -> Promise<Result<Buffer, ReadError>> {
    try {
        let decrypted = await cryptolib.decrypt(fileContents, key);
    } catch (e) {
        return new Err('invalid');
    }
    return new Ok(decrypted);
}

async fn read(path: string, key: string) -> Promise<Result<Something, ReadError>> {
    // Validate
    const validPathResult = validatePath(path);
    if (validPathResult.err) {
        return validPathResult;
    }
    const validPath = validPathResult.val;

    // Read
    const fileContentsResult = await readBytes(validPath);
    if (fileContentsResult.err) {
        return fileContentsResult;
    }
    const fileContents = fileContentsResult.val;

    // Decrypt
    const decryptedResult = await(decrypt, fileContents, key);
    if (decryptedResult.err) {
        return decryptedResult;
    }
    const decrypted = decryptedResult.val;

    return new Ok(decrypted);
}

That's... not better 😕

Yes, I could use .map to chain calculations, but this is a simplified example. Real-world code may be much more complex, and then .map chains get more tedious. I made this experience with early async support in Rust: Initially you had to build future chains, and it was a big pain. Then async/await came, and all of a sudden you could write async code like sync code, without combinators and chaining.

The difference between TS and Rust is that Rust provides a ? operator for early-return of errors. Before that, it had a try! macro that did the same.

Is there any mechanism in NodeJS to emulate this? I'm not aware of any macro-like functionality at the moment, so I don't think this is possible 😕

@KoltesDigital
Copy link

I'm wondering the same thing.

When there's no async, .andThen could do it, although it adds some (minor) performance penalties compared to an early return.

const read = (path: string, key: string) =>
	validatePath(path)
		.andThen((validPath) => readBytes(validPath))
		.andThen((fileContents) => decrypt(fileContents, key));

The problem is that Promise and this library implement two concurrent monad models.

One way to solve that would be to integrate Promises in this library. E.g. new signatures like andThen<T2>(mapper: (val: T) => Promise<Ok<T2>>): Promise<Result<T2, E>>; so that if mapper is sync then the result is sync, and if mapper is async then the result is async. But this only moves await outside of the call, and would imply a waterfall of nested awaits. Kind of ugly as well.

const read = (path: string, key: string) =>
	(await validatePath(path)
		.andThen((validPath) => readBytes(validPath)))
		.andThen((fileContents) => decrypt(fileContents, key));

Another way to do that is having a TypeScript plugin than can transpile a custom extra syntax. This would be an actual early return! But I'm not aware of such project, and this could mess with the other dev tools.

@KoltesDigital
Copy link

Follow-up thinking. My last code could be written this way, thus avoiding the nested await:

const read = (path: string, key: string) =>
	validatePath(path)
		.andThen((validPath) => readBytes(validPath))
		.then((result) => result.andThen((fileContents) => decrypt(fileContents, key)));

I'll actually try this construct on a project right away.

Maybe a TypeScript plugin could detect and optimize this pattern, but maybe the V8 JITter is already doing that.

@joneshf
Copy link

joneshf commented Jul 24, 2022

There's a couple other options: wrap everything in a helper to catch errors, add another common API and deal with the results.

Wrap everything in a helper to catch errors

If there was a helper like:

function catchErr<Value, E>(
  callback: () => Result<Value, E> | Promise<Result<Value, E>>
): Result<Value, E> | Promise<Result<Value, E>> {
  try {
    return callback();
  } catch (error) {
    if (Result.isResult(error)) {
      return error;
    }

    throw error;
  }
}

And the ability to throw an actual Err<_> (so we could differentiate between it being thrown and something else):

interface ErrImpl<E> {
  questionMark(): never;
}

interface OkImpl<T> {
  questionMark(): T;
}

Then, you could do something like:

const readWithCatchErr = (path: string, key: string): Promise<Result<Something, ReadError>> => {
  return catchErr(async () => {
    const validPath: string = validatePath(path).questionMark();
    const fileContents: Buffer = (await readBytes(validPath)).questionMark();
    const decrypted: Something = (await decrypt(fileContents, key)).questionMark();
    return new Ok(decrypted);
  });
};

Add another common API and deal with the results

This is basically the idea suggested above:

One way to solve that would be to integrate Promises in this library.

But with a different name, so the semantics of andThen don't get overloaded.

There's a common pattern in some purely functional languages called traverse. We don't have the type system features to implement it exactly in TypeScript, but a more restricted version using Promise<_> is possible. Assuming we could add traverse to Err<_>/Ok<_>, similar to this:

interface ErrImpl<E> {
  traverse<Value, Error = E>(_callback: unknown): Promise<Err<E | Error>> {
    return Promise.resolve(this);
  };
}

interface OkImpl<T> {
  traverse<Value, Error>(
    callback: (value: T) => Promise<Result<Value, Error>>
  ): Promise<Result<Value, Error>> {
    return callback(this.val);
  }
}

Then you could do something like this:

const readWithTraverse = async (
  path: string,
  key: string
): Promise<Result<Something, ReadError>> => {
  const validPath: Result<string, ReadError> = validatePath(path);
  const fileContents: Result<Buffer, ReadError> = await validPath.traverse(readBytes);
  const decrypted: Result<Something, ReadError> = await fileContents.traverse((contents: Buffer) =>
    decrypt(contents, key)
  );
  return decrypted;
};

The naming probably would be bikeshed, and surely there's an edge case or two in the first idea. But these are some ways you could achieve the flattening you get with Rust without too much noise or going off the separate language idea.

Here's a playground of both ideas.

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