Results May Vary: Going Further with Result Types
Sean Travis Taylor
Posted on August 27, 2024
In the previous post we introduced the Result
type. We saw how always returning a value from a function simplifies the control flow of our program and increases its type safety.
We also saw how encapsulating try/catch
blocks inside our Result
type makes code easier to reason about as a result (ha!) of the reduced number of branching paths in our program. We went from a tree of many branches to a single unidirectional pipe with computations in and results out. There was a prominent drawback to using our new type though.
See it?
In this post we examine the biggest drawback of this pattern and introduce a solution.
If we learn our lesson well from the previous post, we take one small step from the typical imperative approach in our code toward more functional programming. Great! This is another useful tool in our toolkit but the imperative approach to using the Result
type creates new problems. To wit:
// Definition of the Result type omitted for
// brevity. See previous post (https://dev.to/agustus_gloop/results-may-vary-using-result-types-for-predictable-program-flow-1an0)
// Function that doubles the input value
function double(num) {
return num * 2;
}
// Function that calculates the reciprocal of the input
function reciprocal(num) {
if (num === 0) {
throw new Error("Division by zero");
}
return 1 / num;
}
// We have manually extract the value then execute the functions
// This means more branching code paths
function processResult(result) {
if (result.isOk()) {
let value = result.getValue();
try {
value = double(value);
value = reciprocal(value);
return Result.ok(value);
} catch (error) {
return Result.error(error.message);
}
} else {
return result; // If initial result is an error, just pass it through
}
}
const result = Result.ok(10);
const processedResult = processResult(result);
console.log(processedResult);
// Expected: Result { ok: true, value: 0.05, error: null }
Even a few instances of this approach is enough to bring us back where we began: branching code paths and a gauntlet of if/else statements. How can we maintain the tight flow of control we established in the previous post?
Let us introduce the Result.map
method.
The core issue is that we have to extract the value inside our Result
type. First we check if the operation was successful via the Result.isOk
method. Second, in the case of a successful operation, we get the value out of the Result
type; in the case of an error we extract that error.
In either case, once we break the seal on the result we must introduce new code branches to process the value contained within it.
Now a provocative question: do we actually need to get the value out?
With our .map
method we pull from the functional programming toolkit again. Instead of extracting the value contained within our Result
type, we provide functions to our result via the .map
method. These functions are then applied to the value inside of the result.
// Definition of the Result type omitted for
// brevity. See previous post (https://dev.to/agustus_gloop/results-may-vary-using-result-types-for-predictable-program-flow-1an0)
// Function that doubles the input value
function double(num) {
return num * 2;
}
// Function that calculates the reciprocal of the input
function reciprocal(num) {
if (num === 0) {
throw new Error("Division by zero");
}
return 1 / num;
}
// Example of successful transformation
const successfulResult = Result.ok(10)
.map(double) // 10 * 2 = 20
.map(reciprocal); // 1 / 20 = 0.05
console.log(successfulResult); // Expected: Result { ok: true, value: 0.05, error: null }
// Example of transformation with an exception
const failedResult = Result.ok(0)
.map(double) // 0 * 2 = 0
.map(reciprocal); // throws "Division by zero"
console.log(failedResult); // Expected: Result { ok: false, value: null, error: 'Division by zero' }
The provided functions take the value inside our result as an argument, process the value and then return a new value. The outcome of calling Result.map
with our processing function updates the value contained inside the Result
type.
Volia! We are free from having to unseal the value contained in the result just to work with it.
Since we leave the execution of our mapping function to the Result
type, we don't have to worry about wrapping anything inside a try/catch
block. Our Result.map
method does this for us.
Crucially, if any of our mapping functions throws an error or encounters an exception processing is immediately halted. The return value of such a function called with Result.map
is a Result
type with an non-null error property that we can use to learn more.
Here we eliminate the tedium of unwrapping our result in order to continue processing its containing value. The introduction of .map
means we only have to extract the value from our Result
type when we are finished processing. We also gain the benefits of composability and testability with smaller more focused functions provided to the .map
method.
The implementation shown here takes its inspiration from functional programming without plumbing the intricacies of monads and other esoterica that repel developers from functional techniques.
Our focus is on practical programming above all here. Whether you roll your own implementation or use a library, this is a pattern to keep top of mind. Owing to its improved type safety, its composability and its testability, the Result
type offers a number of outstanding benefits that make it well worth exploring this technique in greater depth in your own code.
Posted on August 27, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 29, 2024