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.