ts-results
ts-results copied to clipboard
Early return / try! / ? operator
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 😕
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 await
s. 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.
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.
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.