Using Results in TypeScript
Result
, AKA Either
, is a type that is used to represent the result of an operation that can either succeed or fail. If the operation succeeds, its result represents a value (or non-value). If it fails, its result represents an error.
If you’ve ever written Rust, chances are you’ve already used results. Rust doesn’t have exceptions, so the Result
type is integral to how errors are handled. JavaScript does have exceptions, but there are some key benefits to using results instead which I hope to illustrate.
Why Use Results Over Exceptions?
This post mainly shows how to use results in lieu of exceptions. But what’s so bad about exceptions?
- Exceptions are not type-safe. TypeScript cannot model the behavior of exceptions. Checked exceptions (like in Java) will likely never be added to TypeScript. Not all JavaScript exceptions are
Error
objects (you canthrow
any value). JavaScript exceptions have always been and will always be type-unsafe. - Exceptions are often difficult to understand. It is unclear whether or not a function throws exceptions unless it is explicitly documented. Even source code inspection makes it difficult to know if exceptions might be thrown because of how exceptions propagate. On the other hand, if a
Result
-returning function is inspected, the error cases become immediately clear because errors must be returned. - Exceptions (may) have performance concerns. This isn’t hard science and likely completely negligible, but JavaScript engines in the past have struggled to optimize functions that use exceptions because pathways become unpredictable when the call stack can be interrupted at any time from anywhere.
There’s a reason newer languages like Rust and Go don’t have exceptions. Dave Cheney once said, “Go solves the exception problem by not having exceptions.”
Obligatory Disclaimer
As practical programmers, we are obligated to make practical decisions. Exceptions are used throughout the JavaScript standard library, third-party libraries, and many existing codebases. Choosing to use results over exceptions may seem like swimming upstream and may not be the right choice for you and your team.
Result
Type
The What does a result actually look like in JavaScript? We can model it as an object literal with an ok
property that is either:
true
, in which case it has avalue
property for the resulting value,- or
false
, in which case it has anerror
property for the error object.
In TypeScript, we can represent such a type like this:
export type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
An Example
Before we can see it in action, let’s set up an example. Let’s assume we’re working with a characters.json
file that looks like this:
[
{
"name": "Archie",
"age": 49
},
{
"name": "Wanda",
"age": 30
},
{
"name": "Otto",
"age": 41
}
]
We have a function called printYoungestCharacter
that reads the characters.json
file, finds the youngest character, and prints their name. If an error occurs, it prints that instead. We also have a utility function called readJSON
that attempts to synchronously read a file and parse it as JSON.
export const printYoungestCharacter = (file: string) => {
let characters: any;
try {
characters = readJSON(file);
} catch (e) {
console.error('Error:', e);
return;
}
// Sort the array of characters by age in ascending order
characters.sort((c1, c2) =>
c1.age === c2.age ? 0 : c1.age > c2.age ? 1 : -1,
);
// Get the first character in the sorted array
const [youngestCharacter] = characters;
// Get the name of the first character
const { name } = youngestCharacter;
console.log('Youngest character:', name);
};
export const readJSON = (p: string): any => {
try {
return JSON.parse(fs.readFileSync(p, { encoding: 'utf8' }));
} catch (e) {
throw e; // re-throw exceptions
}
};
This code is imperative: it comprises a sequence of hard-coded instructions to achieve a single outcome. With the help of lodash/fp, we can create the building blocks we need to switch our code from being imperative to being declarative. This is an important step to demonstrate how Result
objects are more useful when combined with functional programming.
Refactoring: Imperative to Declarative
Our goal is to get the name of the youngest character in an array. To begin, let’s create some functions that we know we’ll need. You’ll notice the steps themselves are identical to our original implementation, but this time around we’re going to be creating functions for each step.
- Sort the array of characters by age in ascending order (
sortByAge
). UsesortBy
to create a function that sorts characters by theage
property. - Get the first character in the sorted array (
first
). Usefirst
as-is. - Get the name of the first character (
getName
). Useget
to create a function that gets the value of a character’sname
property.
import first from 'lodash/fp/first';
import get from 'lodash/fp/get';
import sortBy from 'lodash/fp/sortBy';
const getName = get('name');
const sortByAge = sortBy('age');
To achieve our goal, we can implement these steps as a pipeline of functions. pipe
creates a function pipeline which passes the output of each function to the input of the next. When/if the pipeline operator is implemented in JavaScript, we could use that instead.
import pipe from 'lodash/fp/pipe';
...
const youngestCharacterName = pipe(sortByAge, first, getName);
Now we have a function called youngestCharacterName
which gets the name of the youngest character in an array of characters.
One thing I like about functional programming is composability: functions are usually composed of other functions. The youngestCharacterName
function is built with recognizable smaller functions that I already know. An imperative implementation would mean having to carefully inspect low-level JavaScript to infer behavior. The behavior of functional implementations tends to leap out at you, which drastically improves readability.
Next, we can replace our original printYoungestCharacter
function with this:
const printYoungestCharacter = pipe(
readJSON,
youngestCharacterName,
console.log,
);
Pretty succinct, no? There’s just one problem: we’re no longer handling errors. If fs.readFileSync
or JSON.parse
throw an error, the exception will bubble up through our new implementation.
Result
Type
Using the Let’s reimplement readJSON
to work with the Result
type instead. After parsing the file contents, we wrap it in a result, but it’s important that any downstream exceptions are caught and wrapped in a result as well.
By using the Result
return type, we are signaling to our callers that they needn’t worry about exceptions being thrown from our function because any potential errors are wrapped in a Result
object instead.
export const readJSON = (p: string): Result<any> => {
try {
return {
ok: true,
value: JSON.parse(fs.readFileSync(p, { encoding: 'utf8' })),
};
} catch (e) {
return { ok: false, error: e };
}
};
But when we switch from returning any
to returning Result<any>
our printYoungestCharacter
function no longer works because the youngestCharacterName
function isn’t expecting a Result
object. We could alter our original implementation, but there is a better way.
Let’s write a higher-order function: a function that takes one or more functions as its arguments, or returns a function, or, in this case, does both.
export const wrap = <T, R>(fn: (value: T) => R) => (
result: Result<T>,
): Result<R> =>
result.ok === true ? { ok: true, value: fn(result.value) } : result;
This may look quite… involved, but all it’s doing is converting fn(value: T) => R
to fn(value: Result<T>) => Result<R>
. It’s creating a wrapped version of fn
that plays well with Result
objects.
By using wrap
, we create a pipeline where Result
objects pass through each function, whether that function is further altering the value returned by the previous function or simply passing the error along to the next function. The difference is our pipeline now works with Result
objects, instead of sometimes returning a value or sometimes throwing exceptions.
const printYoungestCharacter = pipe(
readJSON,
wrap(youngestCharacterName),
console.log,
);
But we don’t want to print a Result
object—we want to print the youngest character’s name or print the error. To do this, we need to unwrap the result.
We can achieve this using pattern matching: an expression that matches values, ranges of values, various states, etc. called “patterns”.
Unwrapping Results with Pattern Matching
We are going to focus only on the states of Result
objects, of which there are two: success and failure. Let’s write a function that will ultimately call one of two functions based on the state of our result.
export interface Matchers<T, E extends Error, R1, R2> {
ok(value: T): R1;
err(error: E): R2;
}
export const match = <T, E extends Error, R1, R2>(
matchers: Matchers<T, E, R1, R2>,
) => (result: Result<T, E>) =>
result.ok === true ? matchers.ok(result.value) : matchers.err(result.error);
Take a second to digest what the match
function is doing. It is a function that accepts an object containing ok
and err
functions that may return values. match
then returns a function (so it’s a higher-order function, just like wrap
) that splits the control flow in half.
If the result is:
- successful, then we call the
ok
function with the value - or unsuccessful, then we call the
err
function with the error.
The important part is that we continue executing and return whatever ok
or err
return.
Putting it all together, we use match
to unwrap the result at the end of our pipeline. If all is well, we print the youngest character’s name. Otherwise, we print the error.
const printYoungestCharacter = pipe(
readJSON,
wrap(youngestCharacterName),
match({
ok: v => console.log('Youngest character:', v),
err: e => console.error('Error:', e),
}),
);
In JavaScript, we have no formal syntax for pattern matching (although, there is a TC39 proposal), so we must emulate it using functions, but it’s important to note that pattern matching goes far beyond just matching result states. In Rust, match
expressions are a powerful built-in feature. Same with Swift’s switch
statement.
Converting Exception-throwing Functions to Result-returning Functions
We can write a generic function that converts a function that may throw an exception to a function that returns a Result
object. In effect, it encases the return value or the thrown exception in a result. This could be used to limit the number of try
/catch
statements in your codebase to just one.
export const encase = <T, A extends any[]>(fn: (...args: A) => T) => (
...args: A
): Result<T> => {
try {
return { ok: true, value: fn(...args) };
} catch (e) {
return { ok: false, error: e };
}
};
As an example, we can convert our original readJSON
into a pipeline using encase
for fs.readFileSync
and wrap
for JSON.parse
:
export const encasedReadFileSync = encase(fs.readFileSync);
export const readJSON = pipe(
(p: string) => encasedReadFileSync(p, { encoding: 'utf8' }),
wrap(JSON.parse),
);
And yes, point-free nerds, it could be this, too:
import _ from 'lodash/fp/__';
import partial from 'lodash/fp/partial';
...
export const readJSON = pipe(
partial(encasedReadFileSync, [_, { encoding: 'utf8' }]),
wrap(JSON.parse),
);
Using a Library
While the match
, wrap
, and encase
functions (along with lodash) can get you pretty far, we are only scratching the surface of results, algebraic data types, and functional programming. We also haven’t covered asynchronous code (which works perfectly fine with results, but our utility functions may need to be adjusted).
The closest library I’ve found that builds on what we’ve learned is Pratica. It’s written in TypeScript, so it will work well with types. The Result
type is implemented like a class in Practica (just like Rust) for chainable operations.
TL/DR
We can replace exceptions in our code with a simple object type and a few utility functions. Adapting functional programming concepts makes results feel more natural and can improve our code in the process.
However, results are not idiomatic to JavaScript; that’s why we have to emulate the behavior. Eventually, if JavaScript adopts expressive pattern matching and more useful operators, they may become commonplace.