Explicit exceptions - a solution to fragile code dealing with exceptions

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)

  1. 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?
  2. 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.
  3. 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.

I'm not sure how "A bug that the program found during runtime" is any different from "Something that prevented the expected task from completing". Can you elaborate?

If I attempt to open a file, and the file wasn't found, that's not necessarily a bug, it's just "something that prevented the task from being completed". I can catch that specific exception and handle it however I choose. It could be a bug if I know that file is supposed to exist, but whether it is or isn't is for me as the API consumer to decide.

On the other hand, if I pass a dog into a function that expected a cat, that function might throw an error saying "Found a Dog, expected a Cat". This is a programmer error, caused by misusing the API. There's no incentive for the API to throw some special subclassed exception, or to attach a distinguishable property to the error that gets thrown, because there's no incentive for you, as the consumer of this API, to ever handle this specific error any different from other kinds of fatal errors. Assertion errors that indicate that your program got itself into a bad state also fall into this category.

I'm using the words "Error" and "Exception" the same way the dart language defines them (as seen here)

I like this proposal, it looks very interesting in order write clean code, when your code is relying extensively on exceptions and error handling.

However I don't understand why this would be the caller that would declare which exceptions the function would be expected to throw. Maybe there is something I'm missing, but for me it would be more logical to declare which exceptions are expected to be thrown inside the function definition. I don't see why it would be expected to catch a NotFound exception in some places, while in other places that would be considered as an error.

A syntax similar to Java could be used for that:

function getUser (id) throws 'NotFound', 'ServiceUnavailable' {
  if (!user.has(id)) {
    throw new Exception('NotFound', 'User was not found');
  }

  if (!serviceAvailable()) {
    throw new Exception('ServiceUnavailable');
  }

  return users.get(id);
}

Like this, if a caller function is receiving an error that isn't 'NotFound' or 'ServiceUnavailable', it would know that, by the function definition, this was not expected, and this will be escalated to a fatal error like you proposed.

function getUserOrDefault (id, defaultValue = null) {
  try {
    // No use to tell which exceptions we expect each time we are calling the function
    // as it is already declared in the function definition
    return getUser(id);
  }
  catch (ex) {
    // Usual exception handling
    if (ex instanceof Exception && ex.code === 'NotFound') {
      return defaultValue;
    }
    if (ex instanceof Exception && ex.code === 'ServiceUnavailable') {
      return defaultValue;
    }

    // Escalate the exception if not expected
    throw ex;
  }
}

The use of a try-catch around a function call, that has declared its expected exceptions in its definition, could even be enforced by using a linter, in order to ensure that every expected exception is always handled.

Also, I think that this syntax (or even your syntax proposal) should support using the exception class function (if one exists), instead of using the exception code string:

// Create Exceptions
const NotFoundException = new Exception('NotFound', 'User was not found');
const ServiceUnavailableException = new Exception('ServiceUnavailable');

const users = new Map()

function getUser (id) throws NotFoundException, ServiceUnavailableException {
  if (!user.has(id)) throw NotFoundException;
  if (!serviceAvailable()) throw ServiceunavailableException;

  return users.get(id);
}


function getUserOrDefault (id, defaultValue = null) {
  try {
    return getUser(id);
  }
  catch (ex) {
    if (ex instanceof NotFoundException) {
      return defaultValue;
    }
    if (ex instanceof ServiceUnavailableException) {
      return defaultValue;
    }

    throw ex;
  }
}

We could even go further:

We could imagine a simpler catch handling, by allowing to specify the class function (or code string) next to the catch keyword, and also allowing multiple catch blocks after a try one.

function getUserOrDefault (id, defaultValue = null) {
  try {
    return getUser(id);
  } catch NotFoundException (ex) {
    return defaultValue;
  } catch ServiceUnavailableException (ex) {
    return defaultValue;
  }
}

This would allow to get rid of the nested if conditions which are quite verbose, and also get rid of the default exception escalation in order to handle unexpected exceptions.

I find that it would end up looking way more readable, and also more explicit about expected exceptions.

Here would be an even simpler version combining the two catch, as they handle both exceptions the same way:

function getUserOrDefault (id, defaultValue = null) {
  try {
    return getUser(id);
  } catch NotFoundException, ServiceUnavailableException {
    return defaultValue;
  }
}

Here, the symmetry between the function proptotype

throw NotFoundException, ServiceUnavailableException

and the function call catch:

catch NotFoundException, ServiceUnavailableException

would be quite elegant, and explicit about the fact that every expected exceptions are indeed handled.

I know this last part goes a bit beyond your proposal, but I came up with this while I was thinking about it. I think this could be a thing to try to implement at the same time, as we are try to see how we could improve errors and exceptions handling in general.

2 Likes

Great feedback @Clemdz! I really like some of your suggestions. I'll need to digest all of your feedback for a little bit before giving a proper response, but I will mention quickly one reason I was advocating for listing the exceptions at the call site.

I think it really just comes down to the fact that having something like excepts at the call site indicates that the function might stop executing at that point and return early. As a concrete example, lets compare these two examples:

Here's a chunk of code that declares exceptions at the call site:

async function createUser({ username, email }) {
  await addUserToDb(userId, { username, email }) excepts 'UserAlreadyExists', 'ServiceUnavailable'
  return await notifyOtherServersOfNewUser(userId, username)
}

Here, it's obvious that addUserToDb() might not suceed, and might cause the function to stop early. It's also obvious why we can't just refactor this to use a Promise.all().

Now, here's the same code snippet again, but now we're just specifying which exceptions createUser might throw

async function createUser({ username, email }) throws 'UserAlreadyExists', 'ServiceUnavailable' {
  await addUserToDb(userId, { username, email })
  return await notifyOtherServersOfNewUser(userId, username)
}

We've now taken out the visual marker that was telling us that the order of execution of those lines are important. It's very tempting to do a Promise.all() refactor here.

Another reason for listing exceptions at the call site would be for the scenario where multiple function calls might give the same exception, and you want it to propagate in some cases, but escalate in other cases.

For example:

function incrementAge(userId) {
  const currentAge = getUser(userId).age excepts 'UserNotFound'
  // This can also can throw UserNotFound, but we want it to
  // escalate here because we know this should never
  // happen - the last line just checked for the existance of this user.
  setPropertyOnUser(userId, 'age', currentAge + 1)
}

Another example:

function getUser(userId, { userIdOfCaller }) {
  // We've decided that getUser() must be called with a valid userIdOfCaller,
  // or its a programmer error, so we're escalating the NotFound exception here.
  const caller = getUser(userIdOfCaller)

  assertHasRole(caller, 'VIEW_USERS') excepts 'NotAuthorized'
  return getUser(userId) excepts 'UserNotFound'
}

So, I'm not necessarily saying it's better to do it at the call site, just mentioning some reasons why it can be useful. Who knows, maybe some sort of hybrid approach works best.

Alright, I think I'm understanding your vision better. We declare which exception types a particular function might throw. Anything not in its signature will automatically be escalated. Then, every time we call one of these exception-throwing functions, we use a try-catch, and explicitly indicate how we would deal with each type of exception (will we handle it? propagate it? escalate it?)

I can see that being able to solve the problems just as much as my original proposal, as long as a try-catch is used every time an exception-throwing function is called. In order for this try-catch concept to fully work, we would need to expose some easy way for the user to escalate the exception (as they can't just do it by simply omitting the exception from the excepts clause like in my original proposal). So, something like this:

function getUserProperty(userId, propName) throws 'NotFound' {
  try {
    return getUser(userId)[propName]
  } catch 'NotFound' (ex) {
    throw ex // propagate this exception
  } catch 'SomethingElse' (ex) {
    throw Error.fromException(ex) // Escalate the exception.
  }
}

This, unfortunately, can only be enforced to a degree by a linter. Code like this would cause issues:

function doSomething(fn) {
  fn() // Is a try-catch required here? Or not?
}

For the discussion on adding new syntax for exception handling, the error catching pipe operator "|^" that we've been discussing in this thread along with the pattern matching proposal would make for a really elagent solution:

function getUserProperty(userId, propName) throws 'NotFound' {
  const user = getUser(userId) |^ match(?) {
    // Propagates NotFound
    when({ code: 'NotFound', ..._ }) { throw ? }
    // Escalates any other exception
    else if (? instanceof Exception) { throw Error.fromException(?) }
    // Let all other fatal errors pass through
    else { throw ? }
  }
  return user[propName]
}
1 Like

Thanks for your positive feedback ! :)

Indeed that would be an interesting idea! But what would actually do the Error.fromException(ex) method compared to a classic error handling ?

function getUserProperty(userId, propName) throws 'NotFound' {
  try {
    return getUser(userId)[propName]
  } catch 'NotFound' (ex) {
    throw ex // propagate this exception
  } catch 'SomethingElse' (ex) {
    throw new Error("This is actually an error") // Escalate the exception
  }
}

In the same idea we could also use a Exception.prototype.escalate method, that would be defined like:

Exception.prototype.escalate = function () {
  throw Error.fromException(this);
}

so that the escalation gets even more simple:

catch 'SomethingElse' (ex) {
  ex.escalate();
}

However, the question of wether it gets more explicit can be discussed. While we can easily read this as: "escalate the exception", we lose the throw keyword clue that indicate the an error will be thrown to the higher scope.
(And I'm not sure that an hybrid syntax like throw ex.escalate() would really make sense, as "escalate" already implies throwing an error)

Also, back in the case an exception is not specified in the function definition, but it is thrown by the function, what kind of error would be thrown instead? It could be a generic error, as it could also be a new error type, something like: UnexpectedExceptionError.

Indeed, that would be like the no-throw-literal eslint rule (or other few ones), that has the same kind of limitations due to the dynamic nature of the language.

What an ambitious proposal cross-over you are proposing here! :)

I agree - if we provide an escalate function, I would want there to be an explicit "throw" keyword involved. I think this is why I was choosing to use an Error.fromException() wording. Another option would be throw myException.asError(). Or, just let users figure out how to escalate them themself, I guess it's easy enough to do throw new Error('Escalated exception: ' + myException.message). I think I would advocate for the last one - an escalate function can be added in later if there's demand.

I guess I just really want that |^ proposal :p. But in all honesty, I think the pattern matching proposal would be a perfect fit for this kind of exception handling stuff - we pattern match against the different exceptions and handle each one as needed, and we use the default case to forward anything else. It'll let us do instanceof checks, or code property checks, etc. The only thing that's missing is some sort of way to easily catch a thrown exception and put it into the pattern matching structure, which something like the "|^" provides.

But, I'm probably just dreaming here - we'll need to work with what we've currently got in the mean time.

I think just a plain Error instance should be thrown. Unless someone can come up with some reason to want to distinguish this "programmer bug" from other "programmer bugs", I see no reason to subclass it.

Then again, we're often making subclasses for different types of programmer bugs, like "SyntaxError" or "TypeError", etc. Maybe there's some reason for this that I just don't know about ... or maybe there's not a real good reason for it.

It's difficult to reason about the different variants to the problem being discussed here, when we're just talking about smaller code snippets. I think I'll put together a github repository in the next few days that tries to show what it would look like to apply these solutions to a moderately sized problem. I can then update the repo as our discussion evolves.

Alright, I've thrown together this following github repo. I put together a larger example of some code that's using a bunch of exceptions, then I wrote it up in three different formats.

  1. Using my original idea
  2. Using @Clemdz's idea of putting exceptions in the type signature, and handling each type of exception, each step of the way up the call stack
  3. A variation of @Clemdz's ideas (I'll get back to this)

You'll find three folders in that repository that contain the code (that have been numbered 1-3), written after these three proposed ideas, so it's easy to put the files side to side and compare how different solutions look.

It's possible there's some minor bugs in the repo, or that I didn't capture your idea well @Clemdz - if so, please leave some feedback, and I can update the repository. (I plan on just putting future changes or proposed ideas into separate folders, so it's easy to compare them all with each other, and to easily preserve the history for this thread)

One thing I quickly noticed while typing up the second version, is that it quickly got verbose and tedious to explicitly show how every exception type gets handled with every function call that throws exceptions. This led me to create that third folder, which is exactly the same as the second one, except I removed all catch-handlers that were simply escalating exceptions, and let a default catch-all-exceptions handler do that for us. This makes the code a little less verbose, but it's still a lot of code.

Here's a concrete example of what I'm talking about. In folder #2, I wrote a function that looks like this:

function getUsername(userId, { userIdDoingRequest, fallback = null }) throws UnauthorizedEx {
  try {
    return userManager.getProperty(userId, 'username', { userIdDoingRequest })
  } catch NotFoundEx {
    return fallback
  } catch UnauthorizedEx (ex) {
    throw ex
  } catch MissingPropEx {
    throw new Error('Unexpected failure to find the username property.')
  } catch Exception (ex) {
    throw new Error(`Unexpected exception: ${ex.message}`)
  }
}

In folder #3, I rewrote it to not handle the MissingPropEx explicitly, and instead let the catch Exception clause handle escalating it.

function getUsername(userId, { userIdDoingRequest, fallback = null }) throws UnauthorizedEx {
  try {
    return userManager.getProperty(userId, 'username', { userIdDoingRequest })
  } catch NotFoundEx {
    return fallback
  } catch UnauthorizedEx (ex) {
    throw ex
  } catch Exception (ex) {
    throw new Error(`Unexpected exception: ${ex.message}`)
  }
}

(This didn't simplify this particular function very much, but it did simplify others much more)

This change makes it so we're not listing out each type of exception the function might throw in the catch clauses, so it means we wouldn't be able to have an eslint rule force us to explicitly handle each exception type anymore.

1 Like

I am a bit late to this thread but I believe the author is correct error handling in javascript is challenging. I just don't want the cure to be worse than the disease.

Could all of this be handled by having a c/c++ assert style function within a catch block?
Perhaps a catchAssert(e, conditions) ... ?

(I hate it when people chime in after the fact so if I sound like an ignormous it is because I am and will not be offended if you have already thought of this ?)

Nah, don't feel bad - I'm hoping to get a wide variety of opinions on this matter - the more heads we get into this problem, the more likely we can find the solution.

Could you elaborate a bit on this catchAssert idea and how it would work in Javascript? (Maybe an example?)

Great work, indeed, that will simplify reasoning!

(Also, sorry for the delay of the response, I've been quite busy recently!)

Actually (maybe I didn't say it explicitly), but in my proposal, there was also an idea of automatic propagation / escalation of the exception to the outer scope.

If the exception is declared in a function definition, when this function is called inside another caller function, the exception could be either:

  1. implicitly propagated (default behavior if there is no try / catch)
  2. explicitly caught and re-thrown (manual propagation)
  3. explicitly caught and re-thrown as an error (manual escalation)
  4. explicitly caught and a default value is provided instead

In cases (1) and (2), the caller function definition would have to explicitly declare the exception with the throws keyword, so that the propagation continues in the outer scope.

If a function tries to throw an exception that was not explicitly defined in its definition, it would be automatically escalated to an error, and the caller function will receive this error (instead of the exception that caused it).
This would be very simple to understand and to explain: a function cannot throw an exception it has not declared.

So for example, instead of (from your github example #2):

function getUsername(userId, { userIdDoingRequest, fallback = null }) throws UnauthorizedEx {
  try {
    return userManager.getProperty(userId, 'username', { userIdDoingRequest })
  } catch NotFoundEx {
    return fallback
  } catch UnauthorizedEx (ex) {
    throw ex
  } catch MissingPropEx {
    throw new Error('Unexpected failure to find the username property.')
  } catch Exception (ex) {
    throw new Error(`Unexpected exception: ${ex.message}`)
  }
}

we would be able to write directly:

function getUsername(userId, { userIdDoingRequest, fallback = null }) throws UnauthorizedEx {
  try {
    return userManager.getProperty(userId, 'username', { userIdDoingRequest })
  }

  // Provide a default value if not found
  // case (4)
  catch NotFoundEx {
    return fallback
  }

  // No need to catch UnauthorizedEx: it will be automatically propagated
  // (because declared in both `userManager.getProperty` and `getUsername` definitions)
  // case (1)

  // By definition, there is no need to catch MissingPropEx:
  // it will be automatically escalated because MissingPropEx is not declared in `getUsername` function definition
  // But as we want to throw a custom error, we still specify it
  // case (3)
  catch MissingPropEx {
    throw new Error('Unexpected failure to find the username property.')
  }

  // No need to catch any other exceptions: they would have been already escalated
  // And as it it more a "programmer" error than a "program" error
  // This would make little sense to try to catch those errors

Or without any comments:

function getUsername(userId, { userIdDoingRequest, fallback = null }) throws UnauthorizedEx {
  try {
    return userManager.getProperty(userId, 'username', { userIdDoingRequest })
  } catch NotFoundEx {
    return fallback
  } catch MissingPropEx {
    throw new Error('Unexpected failure to find the username property.')
  }

In the end, the code you write is not that different from your except keyword proposal, but from a "thrower" perspective instead of a "catcher" one. Instead of having the information about where an exception can be expected to be thrown, you have the information about what exceptions a function is expected to throw.

Both information are useful on their own:

  • The expect keyword, as you explained, is very useful to know what to expect from the function you are calling, at the place you are calling it. This helps a lot not to jump from file to file, in order to know what exceptions you expect the function to throw. Also, this would prevent developers from refactoring code badly, because they were not aware that the order of the functions was important.

  • The throws keyword in function definition, on the other hand, is very useful to get a description of every exceptions the function can be expected to thrown. Instead of seeking inside the function body to see what it could possibly throw, this is documented right away, in one and only place. The code is now self-documented, also allowing better intellisense from IDEs and linters. Also, the automatic unexpected exception escalation can be done directly before leaving the function scope, keeping everything encapsulated (which would be in my opinion more intuitive and logical).

About the second point, even if we can't directly see which exceptions could possibly be throw on function calls, linters and IDEs would be able to parse function definitions, and provide these information to developers, ensuring that exceptions are handled correctly. However, this would indeed be weaker than using the expect keyword, as not enforced directly by the semantics of the language.

For now, I think I would be more in favor of using the throws keyword inside function definitions, rather than the expect keyword on function calls. The pros I discussed above looks to me more interesting to have, and also because its cons could still be lowered by using linters or other helpers from outside the language. But of course this could still be debatable :)

I've made a PR on your github repository if you want to see the full example with this idea!

1 Like

Look up criticism of Java's checked exceptions. It'd be ill advised to push for this without being aware of the massive amount of criticism of that.

It's also worth noting that you can force consumers to do their due diligence by simply returning a {status, ...data} object where status could be "success" or whatever errors you want to feature. (Plus, JS doesn't have any decent syntax for catching specific exception types like Java does.)

3 Likes

@claudiameadows Thank you for helping research the criticism for Java's exception system - I knew sooner or later we would have to look into some of those issues and address them before getting too far, and the research you did can be a nice starting point into that.

I'll add one more link I remember enjoying. This compares Java's exception system to Rust's, and tries to figure out why Java's exception system is so disliked while Rust's exceptions are so praised.

As for "returning exceptions", that certainly helps a lot, but won't fix all issues, as mentioned here in the original post.

An interesting conversation happened in @Clemdz's pull request (here) that I thought I would mention here, along with some thoughts about it.

@Clemdz attempted to do a little refactoring on the code that was supposed to model his ideas, so that it better reflected what he was proposing. In the process of this refactor, he ran into this function (I'll post the version using the "excepts" syntax I had originally proposed in this post, as it's more concise)

function givePermissionToUsers(permission, userIds, { userIdDoingRequest }) {
  userManager.assertUserHasPermission(userIdDoingRequest, PERMISSIONS.view) excepts 'Unauthorized'
  userManager.assertUserHasPermission(userIdDoingRequest, PERMISSIONS.update) excepts 'Unauthorized'

  const users = []
  for (const userId of userIds) {
    users.push(
      userManager.getUser(userId, { userIdDoingRequest }) excepts 'NotFound'
    )
  }

  for (const user of users) {
    if (!user.permissions.includes(permission)) {
      const newPermissions = [...user.permissions, permission]
      userManager.setProperty(user.id, 'permissions', newPermissions, { userIdDoingRequest })
    }
  }
}

@Clemdz wasn't sure why there were two for loops, even after looking over the existing variations of this chunk of code, and decided to refactor it into one for loop.

Which was a breaking change.

In my original post (see here), I had mentioned that the problem I hoped to solve in this thread was that of trying to make it obvious when reordering certain statements would cause a breaking change. This function that was wrongly refactored was supposed to be a demonstration point of this type of breaking change I was hoping to avoid.

In the first for loop, we're retrieving all of the users using userManager.getUser(). This getUser() function could potentially throw a 'NotFound' exception if a particular user does not exist. If none of the getUser() function calls fail, then we proceed to update the permissions property on all of the users. By consolidating this into one for loop, we are now causing updates to start happening, before all of the NotFound checks were done. In a single-loop approach, if a getUser() call fails half-way through, then half of the users will end up with updated permissions and the other half will not.

When an exception is thrown, it should leave the system in a good state, so that the caller can handle the exception and recover from the issue (if it's not left in a good state, then a fatal error should be thrown instead) - I wouldn't really call "half of the users updated" a good state that can be recovered from (or, at the very least, it's not an ideal state to leave it in, but I'm sure there are some scenarios where this behavior isn't all that bad).

I take this event as living proof that none of the proposed explicit-exception systems really do a great job at solving the refactoring-issue I was hoping to find a solution for in this thread. Certainly, they help - because the exceptions are explicit, a reader of the code can see that certain exceptions were being thrown in the first for loop, and that this refactoring is indeed a breaking change (Without the exception being explicit, it would require the reader to follow the different function calls to see what kinds of exceptions different functions may throw) - but just because it's possible to figure out that the refactoring would be a breaking change, does not mean it's obvious or easily deducible. You almost have to be specifically looking for this type of thing in order to notice it.

@Clemdz mentioned that simply adding some comments explaining the odd ordering of the code would have gone a long way. I would add that unit tests can help catch these types of accidental breaking changes too. This series of events has caused me to think long and hard about this class of bugs, and I realized that I've been looking at this issue from the wrong angle the whole time. These "accidental refactoring" issues I'm trying to prevent are all related to trying to update a resource in an atomic way. In the code snippet presented above, I'm hoping to fetch all user information before updating it, in order to ensure I can perform the updates as a single atomic action - either all of the updates happen, or none of them do. Moving exception-throwing logic first is just one way to handle atomic updates, and only works in certain scenarios. Other techniques are sometimes needed, like rolling back an update if an exception does occur, etc. I'm now doing some additional research into this topic to see what types of libraries/patterns exist to help make atomic updates easier to work with and less error-prone.

The point I'm getting at here is that the original class of bugs I was hoping to solve in this thread is better solved through the means of atomic update libraries/patterns than through an explicit-exception system (though explicit exceptions certainly help out). I'm sure a lot of contributors to this thread are here mainly with the hopes of getting a better exception system in Javascript anyways (and probably didn't care as much about this atomic-bug thing as much as I did).

The tl;dr

I think we can change the direction of this thread to have the focus of enhancing Javascript's exception system in general, and forget about atomic-related bugs I was originally trying to address in my original post.

Alright @claudiameadows, I've read through the list of articles you've provided and tried to extract out all of the arguments against checked exceptions. I'm going to point them out and see how they could have been addressed in Java (when it was first designed). This will let us know how we can avoid these same pitfalls when designing an exception system.

Arguments against checked exceptions and how to deal with them.

1. Checked exceptions integrate poorly with higher-order functions.

I've certainly run into this before. I've wanted to use Java's fancy stream API, which contains useful functions such as a map function for arrays. However, while mapping elements, I needed to be able to throw a certain exception. This unfortunately was not possible because the map function does not declare in its "throws" clause that I might be throwing my custom exception.

This should be solvable by providing to the map function with a list of exceptions you expect it to propagate when calling it, similar to how you might provide type information to a generic function. As far as I can tell, this issue is mostly an annoyance because Java has yet to provide the language-level features to make exception handling work with higher-order functions.

I've also found this article about "anchored exceptions" that I understand is supposed to fix this issue in Java. It's a dense read, and I'm not sure how much I understood from it, but it's still interesting.

2. Adding a new exception to a type signature is a breaking change, and all client code has to update.

There are potentially two reasons why you might want to cause your function to throw a new exception:
A. What used to be a fatal error is now being treated as an exception (because you found that people actually wanted to recover from that scenario). This is probably the most common scenario, and one reason people get annoyed with Java's checked exceptions. This should not be treated as a breaking change to client code, and client's should not be forced to update their own code just to indicate that they still want to treat that exception as a fatal error.
B. What used to be working code is now being treated as an exception. This is a breaking change, and each user of your API should be forced to decide how to handle it.

There are a few ways this could be fixed. I'll point out a couple (this first one isn't necessarily the best idea, but it shows that this doesn't have to be an issue with checked exceptions). Make it so there are two types of exceptions you declare in the function signature - exceptions that you require the end-user to explicitly handle, and exceptions that you don't care if they handle or not (if they don't handle it, it'll auto-escalate). Another option is to loosen things up a bit and not force changes in thrown exceptions to be a breaking change in client code (the "A" scenario is more common anyway) - this also means you can't force a client to explicitly acknowledge each type of exception that might be thrown when calling a function.

3. Checked exceptions may force users to handle an exception they know shouldn't ever happen.

For example, right before indexing into an array, you might first check if your index is within bounds. When you actually index into an array, it might throw IndexOutOfBounds, which you then have to catch and handle, even though you know that exception should never happen (thus, this exception is really a fatal error in this specific scenario). This is a lot of extra annoying boilerplate.

This can be solved by providing terser syntax to automatically escalate exceptions as fatal errors. Right now, Java users have to do an entire try-catch, catch the specific exception, and rethrow it as an instance of RuntimeException.

4. Checked exceptions are verbose

With each solution we decide, there's always going to be a balance with how verbose it is to use, and how secure it makes us. Different syntax, etc, can help out, but there's always going to be a verbosity cost, and it's something we'll have to keep in mind.

Others?

These are the main issues I saw while reading through the articles. If I missed any important ones, please feel free to speak up and we can address it. But, I think the takeaway here is best described by the Anders Hejlsberg from @claudiameadows's last article: "checked exceptions are a wonderful feature. It's just that particular implementations can be problematic". In other words, with some tweaking, all of these things that people hate about checked exceptions can be solved. We just need to keep in mind the places where Java failed in our own design.



An another note, to continue much further with this general conversation about improving Javascript's exception system, I think it would be good to at least try and figure out some specific things we want in an exception system, and some problems we're hoping to solve. Note that the final solution may not be able to solve all of the presented problems (sometimes the extra syntax or verbosity is not worth it).

I'll start of by naming a few:

Problems the new exception system should solve:

  1. Provide a way to self-document what exceptions a particular function can throw/give.
  2. Provide a way to easily escalate exception into fatal errors when you know that particular exception should never happen. (This solves issue #3 with checked exceptions)
  3. It should be difficult to accidentally ignore important exceptions

Features the new exception system should provide:

  1. It should be possible to throw/give a new exception without forcing all users of your API to update their code (because it was a none-breaking change - this solves issue #2 with checked exceptions)
  2. When an unexpected exception occurs, it should be difficult to forget to escalate it (thus, letting it slip through, and potentially getting inappropriately caught and handled)
  3. It should integrate well with higher-order functions (This solves issue #1 with checked exceptions)

Feel free to suggest other points you feel an exception system should provide.

Just adding a new edge to the related-things-on-the-Internet graph:

One of the TypeScript issues also discussing syntax for exceptions: Suggestion: `throws` clause and typed catch clause · Issue #13219 · microsoft/TypeScript · GitHub

3 Likes

What exactly do you mean by "fatal error"? Sounds ominous.

Just to be clear, I wasn't explicitly objecting, just saying you would have to address them to get very far. (Several TC39 members are ex-Java developers, and the similarity won't go missed.)

1 Like