Skip to content

Problematic Try-Catches in JavaScript

Problematic Try-Catches in JavaScript

Problematic Try-Catches in JavaScript

The try-catch syntax is a fundamental feature in most programming languages. It allows us to gracefully handle errors that are thrown in our code, and they do so in a way that is familiar to all programmers.

With that in mind, I'm going to propose that they are also highly misused and have a huge impact on the future maintainability of our codebases, not to mention, force us to sometimes implement error-prone code.

The beauty of using the standard try-catch syntax is that if we come back to a section of our code using try-catch, we immediately know that something in this block of code may throw an error, and we want to ensure that our application doesn't fall over because of it.

Reading the following block of code, we should get the general understanding of what is happening:

try {
  const result = performSomeLogic();
  const mutatedResult = transformTheResult(result);
} catch (error) {
  if (!production) {
    console.error(error);
  } else {
    errorMonitoringService.reportError(error);
  }
}

We can see that the block of code will perform some logic to get a result, then it will mutate that result. On error, it will log the error to the appropriate location.

So what's the problem? πŸ€”

Or rather, what are the problems? Let's look at each in turn!

1. Which method is throwing the error?

If we come back to refactor this block of code, we can't tell simply by looking at each method call in the try block which method may throw.
Is it performSomeLogic() or is it transformTheResult(result)?

To figure this out, we'll need to find where these functions are defined and read through their source to understand which one could potentially throw an error.

Is the function from a third-party library? In that case, we're going to have to go and find documentation on the function, hoping that the docs for the version we are using are still available online, to figure out which function could throw the error.

THIS IS PROBLEMATIC

It's adding additional time and complexity to understand the section of code, reducing its future maintainability. Refactoring or fixing bugs in this area is more complex already!

2. What if both methods should throw?

Here comes a new problem! When both performSomeLogic() and transformTheResult(result) are expected to throw, the catch block does not provide a convenient way to differentiate which threw:

try {
  const result = performSomeLogic();
  const mutatedResult = transformTheResult(result);
} catch (error) {
  // Did performSomeLogic or transformTheResult throw?
  // How can we find out?
}

So, now that both could throw, how do we find out which threw, in the case that we need to handle the errors differently? Do we inspect the error message?

try {
  const result = performSomeLogic();
  const mutatedResult = transformTheResult(result);
} catch (error) {
  if (error.message.includes("performSomeLogic")) {
    // Do error handling specific to performSomeLogic
  } else {
    // Do error handling specific to transformTheResult
  }
}

THIS IS PROBLEMATIC

Now we're coupling our code to an error message, which could change over time, not to mention increasing the difficulty in testing this section of code. There's now two branches here we need to test.

Any developer coming to this section of code to maintain it has to ensure that they take into account the differences in error messages to ensure the errors are handled appropriately.

3. I need to use mutatedResult for another action

Unsurprisingly you may have to use the result you get from a function that could throw to perform another action, similar to the code above where result was used to calculate mutatedResult.

Let's say you now need to call a new function updateModelViaApi(mutatedResult). Where do you put it?

Inside the try-catch after you calculate the mutated result?

try {
  const result = performSomeLogic();
  const mutatedResult = transformTheResult(result);
  const response = updateModelViaApi(mutatedResult)
} catch (error) {
  if (!production) {
    console.error(error);
  } else {
    errorMonitoringService.reportError(error);
  }
}

Surely not. You're only putting it there because you need access to mutatedResult which is within the try scope. If you then had to perform more logic with the response object, would you also put that into the try block?

try {
  const result = performSomeLogic();
  const mutatedResult = transformTheResult(result);
  const response = updateModelViaApi(mutatedResult)

  if(response.status === 200) {
      letsDoSomethingElse();
  }
} catch (error) {
  if (!production) {
    console.error(error);
  } else {
    errorMonitoringService.reportError(error);
  }
}

THIS IS PROBLEMATIC

Ok, our try block is continuing to grow, and going back to point 1, we are making it more and more difficult to understand what our try block is actually doing and further obscuring which function call we are expecting to throw. It also becomes much more difficult to test and more difficult to reason about in the future!

Could we not just move the variable outside the try scope? We could:

let mutatedResult;
try {
  const result = performSomeLogic();
  mutatedResult = transformTheResult(result);
} catch (error) {
  if (!production) {
    console.error(error);
  } else {
    errorMonitoringService.reportError(error);
  }
}

const response = updateModelViaApi(mutatedResult)

if (response.status === 200) {
  letsDoSomethingElse();
}

However, while this does reduce the amount of code in the try block, it still presents us with an issue of future maintainability, as well as a potential bug. We've declared a variable outside our try scope, without assigning it a value.

If an error is thrown before mutatedResult is set, execution will continue and our updateModelViaApi(mutatedResult) will be called with undefined, potentially causing another issue to debug and manage!

We see problems, but what's the solution? πŸ”₯

To fully understand how to solve the problems presented, it's important to understand the goal of the try-catch syntax.

Try-catch allows the developer to execute throwable code in an environment where we can gracefully handle the error.

With this in mind, we have to understand that the implementation of this syntax by the language is essentially what creates these issues. If we look at the example above where we moved mutatedState outside the try scope, we solve an issue, but by doing this we break the functional programming concept of immutable state.

If we think of the try-catch block as a function, then we can see this breach of immutable state much clearer:

let mutatedResult;
tryCatch();
// expect mutatedState to now have a value
const response = updateModelViaApi(mutatedState); 

However, by considering the try-catch block as a function, we can eliminate the problems we talked about earlier.

Having the try-catch logic moved into a function, we:

  • create a consistent pattern of running only the throwable code (Point 1)
  • can handle multiple throwable function calls and handle their individual errors explicitly (Point 2)
  • don't have to worry about block-scoped variables (Point 3)

So how do we transform the try-catch into a function?

Introducing no-try! πŸš€

Luckily we don't have to. There is already a library that has done this for us.
NOTE: It should be noted this is a library I wrote

The library is called no-try and you can read more about it here. It will work in a browser environment as well as a node environment.

So what does no-try let us achieve?

Let's jump back to our first example and see if we can tackle the problem of Point 1 and refactor it to use no-try.

const { useTry } = require('no-try');
// You can also use 
// import { useTry } from 'no-try';

const [error, result] = useTry(() => performSomeLogic());

if (error) {
    console.error(error);
}

const mutatedResult = transformTheResult(result);

We can now see exactly which method we expect to throw an error, making it easier for any developer coming along afterwards to refactor this logic if they need to.

Admittedly, there's a slight cognitive load added to understand what useTry is, as it's not as immediately recognisable as a try-catch but from the naming and the usage, it should be pretty self-explanatory.

Can we also solve Point 2? Individually and explicitly handling errors thrown by multiple throwable function calls? Well, yes!

const { useTry } = require('no-try');

const [error, result] = useTry(() => performSomeLogic());

if (error) {
    console.error(error);
}

const [transformError, mutatedResult] = useTry(() => transformTheResult(result));

if (transformError) {
    notificationService.showError(transformError);
}

Now we can see that both methods may throw an error. We can handle both of these errors individually and without having to write code to figure out which error we're handling, reducing future maintenance.

Finally, tackling Point 3 should now be fairly straight forward. We don't have to worry about block-scoped variables or a try-catch block that's getting bigger and bigger as we need to execute business logic. If an error is thrown, we can exit the function before running code that might rely on a successful outcome:

const { useTry } = require('no-try');

const [error, result] = useTry(() => performSomeLogic());

if (error) {
    console.error(error);
    return;
}

const mutatedResult = transformTheResult(result);
const response = updateModelViaApi(mutatedState); 

if (response.status === 200) {
  letsDoSomethingElse();
}

This is much easier to reason about and it's straightforward to read. We can see what is expected to throw an error, where it is handled, and we aren't placing unnecessary code inside the try-catch block due to limitations presented by the language itself.