Results May Vary: Going Further with Result Types

agustus_gloop

Sean Travis Taylor

Posted on August 27, 2024

Results May Vary: Going Further with Result Types

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 }
Enter fullscreen mode Exit fullscreen mode

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' }
Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
agustus_gloop
Sean Travis Taylor

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