05. Exception Composition

tl;dr October 23, 2019

How do you handle runtime errors without a mess of try…catch and if…else statements? Let’s see how higher-order functions and composition can help on today’s episode of TL;DR, the JavaScript codecast series that teaches working web developers to craft exceptional software in 5 minutes a week.

Transcript

Last episode we saw how Custom Errors can often make our code worse, but Custom Exceptions can help by allowing intermediary functions to focus only on the feature’s happy path. If you’re just now joining us, hop back to the previous episode on Custom Exceptions.

Exceptions are useful when they eliminate if…else statements from calling functions, but at some point an Exception needs to be caught and handled, and that’s where the try…catch statement tends to make a mess of things.

Today we’re continuing to refactor a tiny chatbot we started a few episodes ago that helps outdoor enthusiasts find great trails to hike.

let chatbot = (message) => {
  return viewHike(message);
};

chatbot('view hike mirror lake');
// => 'Details about <mirror lake>'
chatbot('view hike lost lake');
// => 💥 NotFound: lost lake
chatbot('show hike blue ridge');
// => 💥 ValidationError: show hike blue ridge

Like last time, our chatbot only understands one command, view hike. Most of the time this command replies with details about the hike, but when users ask for a hike that isn’t in the database or their syntax is a bit off, the viewHike() function will throw a custom exception like a NotFound error or a ValidationError.

In either case, the chatbot shouldn’t blow up and stop running, so we started by wrapping a try…catch statement around the problematic code.

let chatbot = (message) => {
  try {
    return viewHIke(message);
  } catch (error) {
    return `No such hike.`;
  }
};

chatbot('view hike mirror lake');
// => 'No such hike.'
chatbot('view hike lost lake');
// => 'No such hike.'
chatbot('show hike blue ridge');
// => 'No such hike.'

But we quickly realized that every use of try…catch takes a substantial amount of boilerplate to keep from introducing a catch-all bug, like accidentally suppressing a ReferenceError.

To make sure we only rescued a particular error type, we introduced a simple utility called rescue(): a guard clause which rethrows the error if the type differs from what we intended to catch.

let chatbot = (message) => {
  try {
    return viewHIke(message);
  } catch (error) {
    rescue(error, NotFound);
    return `No such hike.`;
  }
};

chatbot('view hike mirror lake');
// => 💥 ReferenceError: viewHIke is not defined
 let chatbot = (message) => {
   try {
-   return viewHIke(message);
+   return viewHike(message);
   } catch (error) {
     rescue(error, NotFound);
     return `No such hike.`;
   }
 };
chatbot('view hike mirror lake');
// => 'Details about <mirror lake>'
chatbot('view hike lost lake');
// => 'No such hike.'
chatbot('show hike blue ridge');
// => 💥 ValidationError: show hike blue ridge

The problem with rescue() is that it only helps us catch one type of error at a time. So how do we handle both a NotFound error and ValidationError? We could make the rescue() function accept multiple error types, but then we couldn’t customize the fallback message based on the error type.

So do we have to give up the rescue() utility altogether and use cascading if…else statements to uniquely handle different error types? Maybe not if we factor a little further.

Our remaining try…catch boilerplate is starting to turn into an obvious pattern: if we were to reuse this try…catch in another part of the codebase, all that changes is the function to invoke, what type of error to rescue, and what to return if there is an error.

Let’s extract this formula into a function called swallow(), which takes the error type to swallow, a fallback function, and a function that will potentially throw an error.

let swallow = (type, fail, fn) => {
  try {
    return fn();
  } catch (error) {
    rescue(error, type);
    return fail(error);
  }
};

Now we’ll use swallow() to create a new version of viewHike() that is safe from NotFound errors.

let safeViewHike = (message) =>
  swallow(NotFound, () => `No such hike.`,
    () => viewHike(message)
  )
;

let chatbot = safeViewHike;

It seems to work as before! But this code is still pretty verbose, and some might argue it’s more cryptic than simply writing a try…catch with cascading if…else statements. Well, if we just change the signature of swallow() a bit to take advantage of currying, we can eliminate a lot of the extra function calls and argument gathering.

-let swallow = (type, fail, fn) => {
+let swallow = (type) => (fail) => (fn) => (...args) => {
   try {
-    return fn();
+    return fn(...args);
   } catch (error) {
     rescue(error, type);
     return fail(error);
   }
 };
let safeViewHike =
  swallow(NotFound)(() => `No such hike.`)(
    viewHike
  );

Whoah, look at swallow() now! It’s a Higher-Order Function: it takes in an unsafe function that throws a particular kind of error, and returns a safe version of the function.

Because swallow() returns a function that is safe from the NotFound error type, there’s no reason we can’t pass that function into swallow() again to make it safe from a ValidationError too!

let safeViewHike =
  swallow(ValidationError)(() => `Invalid format.`)(
    swallow(NotFound)(() => `No such hike.`)(
      viewHike
    )
  );
chatbot('view hike mirror lake');
// => 'Details about <mirror lake>'
chatbot('view hike lost lake');
// => 'No such hike.'
chatbot('show hike blue ridge');
// => 'Invalid format.'

That nesting is a bit nasty, but this is just the sort of thing the compose() utility is for:

let compose = (...fns) => x => fns.reduceRight((v, f) => f(v), x);

Instead of nesting swallow()s inside each other, we can list them out from top to bottom and feed the original viewHike() function at the very end. It works exactly the same way as manually feeding the results of each swallow() into the other, but it’s much easier to read and maintain.

let safeViewHike = compose(
  swallow(ValidationError)(() => `Invalid format.`),
  swallow(NotFound)(() => `No such hike.`),
)(viewHike);

This style of creating functions without first gathering and passing around all their arguments is called Point-free style, and it’s a big part of what makes functional programming so elegant.

It took us some time to arrive at this design, and many of the intermediate steps seemed a lot worse off than just using try…catch. But just like the Enforcer pattern we covered in an earlier episode, the best way to combine behaviors is through composition. Rather than cascading if-else statements, complex multiple error handling logic, or experimental catch syntax, we handled two kinds of errors through composition.

If you aren’t already in love with function composition, hang tight until the next episode: we’ll use error composition to put a functional twist on a popular Object-Oriented Programming pattern called the Null Object Pattern.

Today, look for try…catch statements in your codebase, and break down the parent function until you can replace the try…catch altogether with swallow(). And if you need to handle multiple error types, just layer them with compose().

That’s it for today. Want to keep leveling up your craft? Don’t forget to subscribe to the channel for more rapid codecasts on design patterns, refactoring and development approaches.

Jonathan Lee Martin

Jonathan is an educator, writer and international speaker. He guides developers — from career switchers to senior developers at Fortune 100 companies — through their journey into web development.