Mistakes happen everywhere, including in javascript code. Today we explore how to deal with (un)expected errors (aka Exceptions). We look at how to deal with them, why you need the try
/ catch
/ finally
syntax and how to throw your own errors.
A sample app
We will approach the topic by creating a quick sample app and making changes to it along the way. The initial state would look something like this:
index.html
<!Doctype html>
<html>
<body>
<input type="text" placeholder="json data">
<button>Submit</button>
<script type="application/javascript">
let input = document.querySelector("input")
let button = document.querySelector("button")
document.querySelector("button").onclick = async()=>{
// set some visual loading state
// and prevent further clicks while working
button.disabled = true
button.innerText = "processing..."
// simulate a long-running task by waiting 2s
await new Promise(r => setTimeout(r, 2000))
//parse input & print age
let user = JSON.parse(input.value)
let age = parseInt(age)
console.log(`You are ${age} years old`)
// undo loading state, do cleanup and enable button
button.disabled = false
button.innerText = "Submit"
input.value = ""
}
</script>
</body>
</html>
Given valid input like {"age": 30}
, this little web page will take an arbitrary json string, set some visual loading indicators in the html markup, wait 2s to simulate a longer-running task, print the user's age and remove the loading indicator.
Catching an exception
As long as the input provided by the user is a valid json string, the code above will work just fine. But what if we put in something that's clearly not json, like }}}bad value
? After the 2s timeout, our console will throw an error similar to this:
On top of that, our button is now stuck in the "processing..." state and can't be clicked anymore, effectively soft-locking the app into an unusable state. This happens because the function JSON.parse()
threw a SyntaxError
exception that was not caught by us. As soon as it did, execution stopped and it skipped all lines below it, so our cleanup never ran.
We can adjust our javascript code to catch the exception instead:
let input = document.querySelector("input")
let button = document.querySelector("button")
document.querySelector("button").onclick = async()=>{
// set some visual loading state
// and prevent further clicks while working
button.disabled = true
button.innerText = "processing..."
try{
// simulate a long-running task by waiting 2s
await new Promise(r => setTimeout(r, 2000))
//parse input & greet user
let user = JSON.parse(input.value)
let age = parseInt(user.age)
console.log(`You are ${age} years old`)
}catch(err){
console.warn(`Ignoring invalid JSON input: ${err}`)
}
// undo loading state, do cleanup and enable button
button.disabled = false
button.innerText = "Submit"
input.value = ""
}
We have now isolated the call to JSON.parse()
in a try
block and, in case it throws an exception again, handle it with the catch
block. The code inside the try
block will stop at any line that throws an exception and immediately jump to the catch
block, skipping any remaining lines in the try
block. That's why in our example, the console.log(`Hello ${user.name}`)
line won't run if JSON.parse()
throws an exception. Code after the catch block runs normally regardless of whether the statements in try
succeeded or catch
has caught an exception, so our cleanup always runs and we aren't soft-locked on errors anymore.
Throwing your own exceptions
Now that we have seen how to deal with exceptions thrown by others, let's join in on the fun. We throw
exceptions when we encounter a situation we consider to be an error. In our example, even json input like {"age": "horse"}
will run and print "Hello NaN" to console. But from our perspective, a user without a valid number as an age would be an error, right?
To handle this, we can check and throw our own error if a user's age can't be parsed as an integer:
if(isNaN(age)){
throw "Age is not a number"
}
We can throw any variable we want, like strings, numbers or objects. For now we just throw a simple error message string.
Why you should throw Error objects
Now that we have seen how easy it is to throw a string, why would we want to throw more complex data types like the Error type? Because it provides a lot of built-in debugging information in addition to our error message, like the name of the file the exception happened in, the line and column within that file:
Adjusting our code to throw an Error type is not much effort, but the benefits are obvious:
if(isNaN(age)){
throw new Error("Age is not a number")
}
Handling exceptions selectively
Now that we have seen how to throw a single exception, let's make the example a little more complex. Assume that in addition to checking if the age is a number, we also throw another exception for users who are less than 18 years old. Let's say our application has some fancy code somewhere else to check for parental permission if the user is a minor but our application doesn't. This creates a problem: how do we distinguish between the exceptions that were thrown?
The solution is to create our own Error types by extending the builtin Error type:
class TooYoungError extends Error {}
class InvalidAgeError extends Error {}
if(isNaN(age)){
throw new InvalidAgeError("age is not a number")
}
if(age < 18){
throw new TooYoungError("user is younger than 18")
}
This simple line of code enables us to still use the Error object under the hood and have all the debugging information for free while throwing a custom error so our catch can distinguish between the types of exceptions it handles:
catch(err){
if(err instanceof InvalidAgeError || err instanceof SyntaxError){
// handle bad json inputs
console.log(`Ignoring invalid json input: ${err}`)
}else{
// let someone else deal with age issues
throw err
}
}
The code now checks what type the error message is using type instanceof
and still ignores invalid age values or malformed json. But if it encounters someone too young for our application it will throw
the err value again. Throwing an exception from a catch
block is called re-throwing. Be re-throwing, our catch
block gives responsibility for handling the exception to the function that called the current function (or it's parent, ....) until one of them either handles the error or it arrives unhandled in the main scope, where it would crash our javascript process once more.
Let's add a catch
block around the entire onclick
event to catch the re-thrown error as well:
let input = document.querySelector("input")
let button = document.querySelector("button")
// define custom error types
class InvalidAgeError extends Error {}
class TooYoungError extends Error {}
document.querySelector("button").onclick = async()=>{
// wrap onclick event handler into try block to catch re-thrown exceptions
try{
// set some visual loading state
// and prevent further clicks while working
button.disabled = true
button.innerText = "processing..."
try{
// simulate a long-running task by waiting 2s
await new Promise(r => setTimeout(r, 2000))
//parse input & greet user
let user = JSON.parse(input.value)
let age = parseInt(user.age)
if(isNaN(age)){
throw new InvalidAgeError("age is not a number")
}
if(age < 18){
throw new TooYoungError("user is younger than 18")
}
console.log(`You are ${age} years old`)
}catch(err){
if(err instanceof InvalidAgeError || err instanceof SyntaxError){
// handle bad json inputs
console.log(`Ignoring invalid json input: ${err}`)
}else{
// let someone else deal with age issues
throw err
}
}
// undo loading state, do cleanup and enable button
button.disabled = false
button.innerText = "Submit"
input.value = ""
// catch re-thrown exception
}catch(err){
console.log(`Caught global exception ${err}`)
}
}
We can now differentiate between exception types and handle them based on what they are (or re-throw the ones we don't want to handle).
What's the finally block for?
So if try
and catch
deal with exceptions, why is there a finally
block as well? The answer to that is not immediately obvious, but can lead to nasty bugs if not understood. Remember when we said that throw
will stop the execution and skip all following code until it is caught by a catch
block? Well, what happens if we re-throw an exception from a catch
block? It does the same thing! In our example, this bug exists as well: if a user's age is less than 18, our cleanup code won't run again and the soft-lock error is back!
This is where the finally
block comes to our rescue: it will always run, regardless of what happens inside it's try
or catch
blocks - even if catch
re-throws an exception, finally
will still execute before another catch
block handles that re-thrown exception.
Editing our application to include the cleanup in a finally
block, it will now look like this:
<!Doctype html>
<html>
<body>
<input type="text" placeholder="json data">
<button>Submit</button>
<script type="application/javascript">
let input = document.querySelector("input")
let button = document.querySelector("button")
// define custom error types
class InvalidAgeError extends Error {}
class TooYoungError extends Error {}
document.querySelector("button").onclick = async()=>{
// wrap onclick event handler into try block to catch re-thrown exceptions
try{
// set some visual loading state
// and prevent further clicks while working
button.disabled = true
button.innerText = "processing..."
try{
// simulate a long-running task by waiting 2s
await new Promise(r => setTimeout(r, 2000))
//parse input & greet user
let user = JSON.parse(input.value)
let age = parseInt(user.age)
if(isNaN(age)){
throw new InvalidAgeError("age is not a number")
}
if(age < 18){
throw new TooYoungError("user is younger than 18")
}
console.log(`You are ${age} years old`)
}catch(err){
if(err instanceof InvalidAgeError || err instanceof SyntaxError){
// handle bad json inputs
console.log(`Ignoring invalid json input: ${err}`)
}else{
// let someone else deal with age issues
throw err
}
}finally{
// undo loading state, do cleanup and enable button
button.disabled = false
button.innerText = "Submit"
input.value = ""
}
// catch re-thrown exception
}catch(err){
console.log(`Caught global exception ${err}`)
}
}
</script>
</body>
</html>
Now if a user enters bad data, we will just ignore it. If a user's age is too low, the finally block makes sure our cleanup code still runs before the exception is caught in the global scope. Finally free of soft-locks!