There is unfortunately no "official" way to achieve this basic functionality in Javascript. I'll share the three most common solutions I've seen in different packages, and how to implement them in modern Javascript (es6+), along with some of their pros and cons.
1. Subclass the Error class
Subclassing an instance of "Error" has become much easier in es6. Just do the following:
class FileNotFoundException extends Error {
constructor(message) {
super(message)
// Not required, but makes uncaught error messages nicer.
this.name = 'FileNotFoundException'
}
}
Complete example:
class FileNotFoundException extends Error {
constructor(message) {
super(message)
// Not required, but makes uncaught error messages nicer.
this.name = 'FileNotFoundException'
}
}
// Example usage
function readFile(path) {
throw new FileNotFoundException(`The file ${path} was not found`)
}
try {
readFile('./example.txt')
} catch (err) {
if (err instanceof FileNotFoundException) {
// Handle the custom exception
console.log(`Could not find the file. Reason: ${err.message}`)
} else {
// Rethrow it - we don't know how to handle it
// The stacktrace won't be changed, because
// that information is attached to the error
// object when it's first constructed.
throw err
}
}
If you don't like setting this.name to a hard-coded string, you can instead set it to this.constructor.name, which will give the name of your class. This has the advantage that any subclasses of your custom exception wouldn't need to also update this.name, as this.constructor.name will be the name of the subclass.
Subclassed exceptions have the advantage that they can provide better editor support (such as autocomplete) compared to some of the alternative solutions. You can easily add custom behavior to a specific exception type, such as additional functions, alternative constructor parameters, etc. It also tends to be easier to support typescript when providing custom behavior or data.
There's a lot of discussion about how to properly subclass Error out there. For example, the above solution might not work if you're using a transpiler. Some recommend using the platform-specific captureStackTrace() if it's available (I didn't notice any difference in the error when I used it though - maybe it's not as relevant anymore ♂️). To read up more, see this MDN page and This Stackoverflow answer.
Many browser APIs go this route and throw custom exceptions (as can be seen here)
Note that babel doesn't support this solution very well. They had to make certain trade-offs when transpiling class syntax (because it's impossible to transpile them with 100% accuracy), and they chose to make instanceof checks broken on babel-transpiled classes. Some tools, like TypeScript, will indirectly use babel, and will thus suffer from the same issues depending on how you've configured your TypeScript setup. If you run this in TypeScript's playground with its default settings today (March 2022), it will log "false":
class MyError extends Error {}
console.log(MyError instanceof Error)
2. Adding a distinguishing property to the Error
The idea is really simple. Create your error, add an extra property such as "code" to your error, then throw it.
const error = new Error(`The file ${path} was not found`)
error.code = 'NotFound'
throw error
Complete example:
function readFile(path) {
const error = new Error(`The file ${path} was not found`)
error.code = 'NotFound'
throw error
}
try {
readFile('./example.txt')
} catch (err) {
if (err.code === 'NotFound') {
console.log(`Could not find the file. Reason: ${err.message}`)
} else {
throw err
}
}
You can, of course, make a helper function to remove some of the boilerplate and ensure consistency.
This solution has the advantage that you don't need to export a list of all possible exceptions your package may throw. You can imagine how awkward that can get if, for example, your package had been using a NotFound exception to indicate that a particular function was unable to find the intended resource. You want to add an addUserToGroup() function that ideally would throw a UserNotFound or GroupNotFound exception depending on which resource wasn't found. With subclassed exceptions, you'll be left with a sticky decision to make. With codes on an error object, you can just do it.
This is the route node's fs module takes to exceptions. If you're trying to read a non-existent file, it'll throw an instance of error with some additional properties, such as code, which it'll set to "ENOENT" for that specific exception.
3. Return your exception.
Who says you have to throw them? In some scenarios, it might make the most sense to just return what went wrong.
function readFile(path) {
if (itFailed()) {
return { exCode: 'NotFound' }
} else {
return { data: 'Contents of file' }
}
}
When dealing with a lot of exceptions, a solution such as this could make the most sense. It's simple to do, and can help self-document which functions give which exceptions, which makes for much more robust code. The downside is that it can add a lot of bloat to your code.
complete example:
function readFile(path) {
if (Math.random() > 0.5) {
return { exCode: 'NotFound' }
} else {
return { data: 'Contents of file' }
}
}
function main() {
const { data, exCode } = readFile('./example.txt')
if (exCode === 'NotFound') {
console.log('Could not find the file.')
return
} else if (exCode) {
// We don't know how to handle this exCode, so throw an error
throw new Error(`Unhandled exception when reading file: ${exCode}`)
}
console.log(`Contents of file: ${data}`)
}
main()
A non-solution
Some of these solutions feel like a lot of work. It's tempting to just throw an object literal, e.g. throw { code: 'NotFound' }. Don't do this! Stack trace information gets attached to error objects. If one of these object literals ever slips through and becomes an uncaught exception, you won't have a stacktrace to know where or how it happened. Debugging in general will be much more difficult. Some browsers may show a stacktrace in the console if one of these objects go uncaught, but this is just an optional convinience they provide, not all platforms provide this convinience, and it's not always accurate, e.g. if this object got caught and rethrown the browser will likely give the wrong stacktrace.
Bonus: Explicit Exceptions
When your package starts dealing with a lot of exceptions, I would recommend using the "return exception" method as described above, to help you keep track of which exceptions can come from where (at least use it internally within your package - you can still throw things for your package users). Sometimes even that solution is not good enough. I put together a little explicit-exceptions package to help in these scenarios (as can be found here. There's also a light version that you can just copy-paste into your project here). The idea is to require users to explicitly list which exceptions they expect a function call to provide. This makes it very easy to follow the path of an exception.
try {
// readFile() will give an exception.
// In unwrap(), you list which exceptions you except, to help with self-documentation.
// runtime checks help ensure no other kinds of exceptions slip through.
return unwrap(readFile('./example.txt'), ['NotFound'])
} catch (err) {
// Handle the exception here
}
Complete example:
// You must have the explicit-exceptions package for this code to run
const { Exception, wrap, unwrap } = require('explicit-exceptions')
const readFile = wrap(path => {
throw new Exception('NotFound')
})
function main () {
try {
return unwrap(readFile('./example.txt'), ['NotFound'])
} catch (ex) {
if (!(ex instanceof Exception)) throw ex // Not an exception, don't handle it
console.assert(ex.code === 'NotFound')
console.log('File not found')
}
}
main()