A lot of JavaScript developers speak in exceptions. However, JavaScript does not have any defined practices on “good exception handling”. What does good mean? All using try/catch, .catch for Promises, and window.onerror in the browser or process.on for Node.js? Just http/file reading/writing calls? 3rd party/vendor systems? Code with known technical debt? None “because fast, dynamic language”?
In my view, good exception handling is no exceptions. This means both writing code to not throw Exceptions, nor cause them, and ensuring all exceptions are handled.
However, that’s nearly impossible in JavaScript as it is a dynamic language and without types, the language encourages the accidental creation of null pointers. You can adapt certain practices to prevent this.
One in the particular is not using async await.
A warning, this is a minority view, and only some functional languages hold this view. I also acknowledge my Functional Programming bias here. JavaScript accepts all types of coding styles, not just FP.
The Promise
Promises are great for a variety of reasons; here are 4:
- They have built-in exception handling. You can write dangerous code, and if an Exception occurs, it’ll catch it, and you can write a
catch
function on the promise to handle it. - They are composable. In functional programming, you create pure functions, which are rad by themselves, and you wire them together into pipelines. This is how you do abstraction and create programs from functions.
- They accept both values and Promises. Whatever you return from the then, the Promise will put into the next then; this includes values or Promises, making them very flexible to compose together without worry about what types are coming out.
- You optionally define error handling in 1 place, a
catch
method at the end.
const fetchUser => firstName =>
someHttpCall()
.then( response => response.json() )
.then( json => {
const customers = json?.data?.customers ?? []
return customers.filter( c => c.firstName === 'Jesse' )
})
.then( fetchUserDetails )
.catch( error => console.log("http failed:", error) )
However, they’re hard. Most programmers do not think in mathematical pipelines. Most (currently) think in imperative style.
Async Await
The async and await keywords were created to make Promises easier. You can imperative style code for asynchronous operations. Rewriting the above:
async function fetchUser(firstName) {
const response = await someHttpCall()
const json = await response.json()
const customers = json?.data?.customers ?? []
const user = customers.filter( c => c.firstName === 'Jesse' )
const details = await fetchUserDetails(user)
return details
}
But there is a problem, there is no error handling. Let’s rewrite it with a try/catch:
async function fetchUser(firstName) {
try {
const response = await someHttpCall()
const json = await response.json()
const customers = json?.data?.customers ?? []
const user = customers.filter( c => c.firstName === 'Jesse' )
const details = await fetchUserDetails(user)
return details
} catch(error) {
console.log("error:", error)
}
}
However, there are also some nuances. For example, we want to separate the error handling for someHttpCall
and it’s data handling from fetchUserDetails
.
async function fetchUser(firstName) {
try {
const response = await someHttpCall()
const json = await response.json()
const customers = json?.data?.customers ?? []
const user = customers.filter( c => c.firstName === 'Jesse' )
try {
const details = await fetchUserDetails(user)
return details
} catch(fetchUserDetailsError) {
console.log("fetching user details failed, user:", user, "error:", fetchUserDetailsError)
}
} catch(error) {
console.log("error:", error)
}
}
This can get more nuanced. Now you have the same problem you have with nested if statements, it’s just quite hard to read. Some don’t view that as a problem.
Golang / Lua Style Error Handling
The Golang and Lua devs do view that as a problem. Instead of Exception handling like JavaScript/Python/Java/Ruby do, they changed it to returning multiple values from functions. Using this capability, they formed a convention of returning the error first and the data second. This means you can write imperative code, but no longer care about try/catch because your errors are values now. You do this by writing promises that never fail. We’ll return Array’s as it’s easier to give the variables whatever name you want. If you use Object, you’ll end up using const or let with the same name which can get confusing.
If you use traditional promises, it’d look like this:
const someHttpCall = () =>
Promise.resolve(httpCall())
.then( data => ([ undefined, data ]) )
.catch( error => Promise.resolve([ error?.message, undefined ]) )
If you are using async await, it’d look like this:
function someHttpCall() {
try {
const data = await httpCall()
return [ undefined, data ]
} catch(error) {
return [ error?.message ]
}
}
If you do that to all your async functions, then when using your code, it now looks like this:
async function fetchUser(firstName) {
let err, response, json, details
[err, response] = await someHttpCall()
if(err) {
return [err]
}
[err, json] = await response.json()
if(err) {
return [err]
}
const customers = json?.data?.customers ?? []
const user = customers.filter( c => c.firstName === 'Jesse' );
[err, details] = await fetchUserDetails(user[0]);
if(err) {
return [err]
}
return [undefined, details]
}
Then if all your functions look like this, there are no exceptions, and all functions agree to follow the same convention. This has some readability advantages and error handling advantages elaborated on elsewhere. Suffice to say, each line stops immediately without causing more errors, and secondly, the code reads extremely imperative from top to bottom which is preferable for some programmers.
The only issue here is not all errors are handled despite it looking like it. If you mispell something such as jsn
instead of json
or if you forget to wrap a function in this style like response.json
, or just generally miss an exception, this style can only help you so much.
Additionally, you have to write a lot more code to put the error first, data last. The worse thing about this style is the constant checking if(err)
. You have to manually do that each time you call a function that could fail. This violates DRY pretty obnoxiously.
Conclusions
You know what doesn’t violate DRY, isn’t verbose, and handles all edge cases for exceptions, only requiring you to put exception handling in one place, but still remains composable?
Promises.
const fetchUser => firstName =>
someHttpCall()
.then( response => response.json() )
.then( json => {
const customers = json?.data?.customers ?? []
return customers.filter( c => c.firstName === 'Jesse' )
})
.then( fetchUserDetails )
.catch( error => console.log("http failed:", error) )