Update: There's a proposal repo out now over here that explains in-depth where this proposal currently stands (it's pretty different from what I originally proposed in this post). Feel free to browse it and leave feedback.
I've been thinking about exception handling in Javascript a lot recently, and how difficult it is to work with them in a stable and clean way. The ideas I'll present here reflect some of the conclusions I've reached about how exception handling can be improved while staying as close as possible to the spirit of Javascript.
Note: Minor updates have been made (and will continue to be made) to this post, to clarify details or link you to interesting parts of this thread. Bigger updates or updates that have affected the current conversation are explicitly marked.
Background
Just so we're on the same page, I'll be using the following terminology (some of which I've made up):
The types of errors (Updated: Improved clarity of these definitions):
- Error: A bug that the program found during runtime.
- Exception: Something that prevented the expected task from completing. An important trait of an exception is that a caller should be able to reliably catch and recover from an exception. If it's not possible to do this in a reliable way, then the exception is about as useful as any other error. (To learn more about why this stability is so important, see the conversation that started around here)
The ways of dealing with exceptions:
- "Propagating an exception": Calling a function, receiving an exception, and just passing it back to your caller
- "Escalating an exception": Calling a function, receiving an exception, recognizing that the exception is indicative of a flaw in the program, and throwing an error to signal this. (e.g. a FileNotFound exception might be escalated to an error if that file is part of your git repo, and should always be packaged with your program).
- "handling an exception": e.g. you catch a FileNotFound exception and return some default value. (Update: I've noticed that sometimes this word "handling" is used in a looser way to just mean "dealing with the exception in any way", whether through propagation, escalating, actual handling, etc. It might be good if we found another term to describe the general idea of "deal with in any way")
- "Translating an exception": Recreating a third-party exception to fit the domain of your library. e.g. changing a database library's "ObjectNotFound" to "UserNotFound". A translation may including changing what types of exceptions are thrown. e.g. the database library may have you distinguish their exceptions by using instanceof checks, but you may have your end users distinguish your UserNotFound exception with a property on the exception.
The problem
(this section has had major updates)
Here is the original problem this thread was attempting to solve.
Original Problem
This is the basic problem I'm hoping to solve in this thread: There's no easy way to know what exceptions a particular function call might throw. Code that heavily relies on exceptions can become extremely brittle because of this (the paths of the exceptions are not obvious, and seemingly ok code changes could cause breaking changes to an API, or introduce bugs).
Take this example:
async function createUser({ username, email }) {
await addUserToDb(userId, { username, email })
return await notifyOtherServersOfNewUser(userId, username)
}
There's no way to know, but these functions must be called one after another (you can't refactor this and use a Promise.all()). addUserToDb() might throw a UserAlreadyExists exception, and stop the flow of code, before a new user is accidentally half-double-added by notifyOtherServersOfNewUser(). Code like this is extremely fragile, and minor code changes could introduce bugs, make breaking changes to what exceptions it gives back, etc, and yet, this is currently the standard way to work with exceptions.
Does this bug anyone else? Or just me?
It was decided here that there are better ways to solve the original problem, so the focus got shifted to a different, more important problem that's also related to exceptions and is a problem that different exception systems proposed in this thread were already doing a good job at solving.
The issue is that Javascript's system of throwing and catching things works great for programmer errors, but has lots of issues when used to throw custom exceptions. The reason is that the idea of invisibly propagating exceptions wreaks havoc on the idea that exceptions should be part of a function's API. Here are the three main issues:
(These points have been copied from this post, which outlined this new focus. It goes into some different details about these issues than what I'm putting here)
- It's near impossible to figure out what exceptions a particular function might throw. How can you keep the API stable if you don't even know what the API is?
- It's difficult to refactor code without making breaking changes. You want to make a public-facing function stop using function f internally, and use g instead, as it better suits what you're trying to achieve. Because it's difficult to know what exceptions f() and g() throw, it will be difficult to know if this change will be a breaking change or not.
- They're difficult to contain and control. Throwing a new exception deep down in the low levels of your project can cause who knows how many of your public-facing functions to start throwing that new exception. The public will stumble into these new exceptions and start relying on them because it's reasonable to expect that an exception with a distinguishing code is supposed to be able to be singled out, caught, and handled. You've accidentally added exceptions to your API (not a breaking change) and can't remove them without it being considered a breaking change.
Not a solution
Returning exception codes helps but doesn't actually fix this issue.
If we were to return exception codes instead of throwing exceptions, then we would at least know which functions might give an exception, but in scenarios where we're just propagating an exception, we will have no idea what exceptions they give and how we're allowed to refactor the code. Plus, it makes the code much more verbose and harder to read - the added complexity alone could introduce other bugs.
async function createUser({ username, email }) {
let errCode, result
errCode = await addUserToDb(userId, { username, email })
if (errCode) return { errCode }
;({ result, errCode } = await notifyOtherServersOfNewUser(userId, username))
if (errCode) return { errCode }
return { result }
}
Note that in typescript, returning exceptions can actually be a valid solution, because you can list in the type signature all of the different types of exceptions that may be returned. See here.
A potential solution
(This section has been updated to clarify that it's not expected for people to put all exceptions a function might throw into the proposed excepts clause)
All we really need is the following rule to make code such as the above much more stable: "Any time you call a function that might give an exception, you explicitly specify which of its exceptions you want to receive". Any exceptions that gets thrown that you don't want to receive will automatically escalate into a fatal error (i.e. the language will automatically do something like throw new Error(yourException.message)
.
What this looks like in practice
const users = new Map()
function getUser(id) {
if (!user.has(id)) {
// Throwing an exception. Exception is a subclass of Error, that has special
// language-level properties. The first parameter is an error-code, used to
// distinguish it from other exceptions. The second (optional) parameter is
// the Exception's message. If the exception is escalated to an error,
// this message will become part of the escalated error message.
throw new Exception('NotFound', 'User was not found')
}
if (!serviceAvailable()) {
throw new Exception('ServiceUnavailable')
}
return users.get(id)
}
function getUserOrDefault(id, defaultValue = null) {
try {
// The new `excepts` keyword is used to declare which exceptions you
// want to handle. Here, we expect getUser() to throw a
// NotFound or ServiceUnavailable exception, and we want to handle both.
// Ommiting the `excepts` would imply that we expect it to not throw any
// exceptions. We can purposefully omit an exception code from this list,
// if we don't have a way to handle that exception, and we want the language to
// escalate it if it ever gets thrown.
return getUser(id) excepts 'NotFound', 'ServiceUnavailable'
} catch (ex) {
// We can catch and handle the NotFound exception like you would expect
if (ex instanceof Exception && ex.code === 'NotFound') {
return defaultValue
}
if (ex instanceof Exception && ex.code === 'ServiceUnavailable') {
return defaultValue
}
// If getUser() happened to throw an exception that wasn't 'NotFound' or
// 'ServiceUnavailable', it would be escalated, and an instance of Error
// would have been thrown instead, explaining what happened. In this
// example, such an error would be rethrown here, just like any other
// programmer error that we may have caught.
throw ex
}
}
Backwards compatibility
The idea of an Exception class such as the one being used here is something I've already proposed in a separate thread over here. I had realized that having a standard exception class in-and-of-itself could be a beneficial addition to the language, which is why I had made a separate thread to discuss it. Ultimately it was decided in that thread that it would not be good to encourage everyone to switch to using a standard exception class, as it would cause minor breaking changes.
What's going on here is different. There is no push for everyone to change their existing code to start using this new exception system. This new exception system is only helpful to make the implementation details of particular libraries more stable. Actually making them part of your API and throwing instances of Exception to your end-user does not provide the end-user with any additional benefits, even if the end-user wishes to use this explicit-exception system also. A library may choose to do so anyway, as it may be more convenient, but there's absolutely no reason to encourage an API change.
The reasoning is not obvious at first, but it boils down to domain boundaries. Whenever you call a function from a third party library that might throw an exception, the first thing that should be done is to "translate" the exception into your domain (e.g. translate a database's "ObjectNotFound" exception to your user-management library's "UserNotFound"). It's obvious which exceptions you expect to receive because you're translating all of the expected exceptions at the call site. Any exceptions that you don't translate (or handle) are effectively treated as program errors, as they wouldn't (or shouldn't) be handled anywhere else besides that original call site. This effectively forces you to code in a way that provides all of the benefits of the explicit-exceptions system, without one being present.
Other solutions proposed in this thread
During the course of this conversation, another solution has been brought up here that would also satisfy the requirements of this post. @Clemdz proposed doing something similar to Java's checked exceptions and putting the list of exceptions a function might throw in the function signature. I created this github repository as a way to compare what the currently proposed solutions look like, future proposed ideas will be added to the repository, and this section will be updated to mention them too.
Conclusion
This whole explicit-exception idea is something I've been mulling over for a while now. I've tried using some different solutions in native Javascript - in fact, not that long ago I actually created an npm package that attempts to solve this problem in a similar format to what's being proposed here. However, without some native help, there's only so much that that package can do, and it can be a little verbose to use it.
I presume I'm not the only one who's had issues with the problems described above, so I thought I would bring these problems, along with one potential solution here for this community to discuss. I love the discussions that happen here, and how ideas get shaped and improved through others' input, and I'm hoping that together we can find a good way to solve this "fragile exceptions" problem - so feel free to bring up completely different ideas.