I just made some updates to the original post to try and clarify some things, and update it based on the conversation going on.
I also realized that I had not explained my proposal well, and can see your confusion @lightmare that you needed to put all of the exceptions that a function throws into the "excepts" clause - because that's pretty much the way I had worded it . Maybe I was trying to dumb down the description of it too much, and wasn't being careful enough in how I wanted to capture my idea.
Yes, I can get onboard with that, including the 3 points you made. Barring some caveats...
If you mean the implementation breaking prior contract specifically tying certain inputs to certain outputs or exceptions, then yes, that's obviously a breaking change. That is true in general, not exclusive to this topic.
If you also mean changes in the declared set of propagated exceptions, not accompanied by breaking changes in behaviour, then I disagree. Taking an exception out of the public API, or adding an exception to the public API, cannot in itself be a breaking change.
exceptions as a union type
Let's say we have a public function declaring it throws X or Y. You can think of it as having an attached exception type: excepts: Error | X | Y
If we refactor the function such that it no longer needs to throw X, we can take it out and the type becomes: excepts: Error | Y
This is compatible with the old one. Any code that worked with Error | X | Y will still work.
Now we add some sanity checks, and start throwing Z where previously it was Error. The exception type then becomes: excepts: Error | Y | Z
This, too, is compatible with the old one. Any code that worked with Error | X | Y will still work, because Z extends Exception extends Error.
Response to proposed solution
This is where we disagree.
Naturally, we want to communicate this information to the api-user. For which we need a tool that extracts propagated exceptions from function body, and produces some documentation. If we're parsing the code with a tool already, then instead of asking the engine to auto-escalate exceptions for us, we could transpile every statement with an excepts clause into:
try {
ORIGINAL_STATEMENT
} catch (ex) {
for (let t of EXCEPTS_LIST) {
if (ex instanceof t) throw ex
}
throw UNHANDLED(ex)
}
I think that'd be a good third (?) step in exploring this whole idea.
That (emphasis mine) part is not something I would gloss over, though. You'd have to add the extra exception to every call site that (indirectly) leads to g().
A similar issue will arise if you discover a new failure mode deep inside your library and want to propagate a new exception all the way up to the public.
Agreed, barring "acknowledging the existence" which I think is irrelevant. If you start throwing a new exception, and the caller doesn't update their error handling, they're going to deal with it like with any other Error coming from the library. No new breakage on their side, whether you acknowledge the exception or not.
Thanks for pointing this out - I mostly mean your first point. Saying things like "removing an exception is a breaking change" is an oversimplification that I need to stop saying because there are scenarios where this is not true.
There's sort of an arrow going on:
error -> exception -> normal behavior
Making changes to a function that causes its behavior to travel right on this arrow is ok (e.g. what used to throw an error now just throws an exception or just works). However, any changes that go against this arrow is a breaking change.
I don't think a tool is necessary unless you already have some auto-documentation system set up. Most documentation has to be written by reading and understanding what the function does, this doesn't have to be different. What's more, this exception system can be used in more places than third-party libraries. For example, it can be used by a server, and at the top-most function, any exceptions will be translated to REST responses. Or it can be used by the UI, and all exceptions thrown within the UI will also need to be caught and handled within the same UI (or escalated).
But, you're certainly right that such tooling would be convenient for some users. And yes, what you showed would be an accurate way to transpile it. Are you suggesting that end-users use transpilers to get this feature, instead of having Javascript natively handle it? Do end users use transpilers for non-standard Javascript features (unless you're using some framework that has you do so - frameworks are able to get away with a lot of things that standard projects can't).
This is fair, and this is probably the primary reason why people are always a little scared of stricter exception systems. But, is there a good alternative? I think this is an issue with all strict exception systems that try to solve the presented problems (Rust's, Java's, etc).
There's also the point that many times you might not want to actually propagate the new exception automatically to all public functions that eventually use this deep-down function. It might make more sense for some functions to handle it, or escalate it. Because the exception's path is difficult to follow, it's difficult to know which places should be updated to add handling/escalation logic (see the example scenario I gave with issue #3 of invisible auto-propagation here of why you sometimes need to handle exceptions).
This is absolutely right - changing behavior that used to be an error into an exception is not a breaking change, and any exceptions that are not dealt with right at the call sight of a third-party library are usually just treated as errors everywhere else in the codebase.
The issue isn't that the end-user would need to make any changes (they wouldn't), the issue is that you've accidentally introduced a new exception to your public API, released it, and now the end user can start depending on that exception. This was not a breaking change, but there's also no reverting this change because doing so would be a breaking change. You're now stuck having to support this unintentionally exposed exception, which, in the example I gave (in point 3 here), this exception was only supposed to be an internal-use-only exception. Maybe you didn't want to add official support to it yet for a number of reasons, like, you weren't sure yet on the naming of the exception, or you weren't sure if different functions should throw differently names exception, or if they should all throw the same exception, or you just didn't want the caller to handle this issue this way.
So, even though changes to the list of exceptions a function can throw isn't always a breaking change, it still should be intentional.
In this post, I'd like to follow up on some ideas you discussed, and also to argue in favor of the throws syntax while sharing a parallel I find interesting between function parameters and thrown exceptions.
Actually, exceptions are not quite different from function parameters
To begin, let's take this simple function as an example:
/**
* @param {number} a
* @param {number} b
* @return {number}
* @throws {DivideByZeroException}
*/
function (a, b) {
if (b === 0) throw DivideByZeroException;
return a / b;
}
As today, the way exceptions are declared and used is implicit, the only way to know which exceptions are being thrown by a function is either to look inside the function body, or to have a look at the jsdoc (if present) annotating the function with a @throws directive.
But actually, if it was also the case for function parameters, this would be like writing:
It works, but it feels really wrong, because we do have a much better and cleaner solution to write this (solution that we are lacking in the case of exceptions).
Also, this feels really weak and error-prone, because everything happens inside the function body, and there is no way to declare and enforce how this function should be explicitly used by other.
I think this parallel really helps to understand how handling exceptions is like today, and why we need a better and cleaner way to do so.
That's also why I prefer the throws syntax over the excepts one, because it is directly documenting inside the function definition what exceptions it throws, and this information is not lost inside the body of other functions calling it:
/**
* @param {number} a
* @param {number} b
* @return {number}
* @throws {DivideByZeroException}
*/
function (a, b) throws DivideByZeroException {
if (b === 0) throw DivideByZeroException;
return a / b;
}
About java throws syntax
Also to make it clear (if it wasn't the case), the throws syntax I proposed based on Java checked exceptions was meant to be similar in terms of syntax, and not behavior. In my proposal there was already the idea of automatic exception escalation by default, without having to explicitly propagate or escalate exceptions each time we are calling the function. This lower the concern of breaking backward compatibility, because if a function stops throwing an exception, the caller function will not break.
Speaking of exception backward compatibility, I'd like to continue my parallel with function parameters. I saw you speak of exceptions breaking backward compatibility as if was meant to change a lot, but do you imagine writing an API function where you frequently change the parameters it takes as its input? I think other programmers will be really unhappy.
Indeed there are cases where changes are not necessarily breaking changes, like adding new parameters add the end of the definition, or adding new properties in a destructured object parameter. In the case of exceptions, removing one, or transforming an error to an exception would not be a breaking change for example. But of course this is not because we can that we should.
About aggregating exceptions
Also, I'd like to discuss about the fact that you could have a lot to exceptions to handle, being a pain to write each time. I do see why aggregating exceptions could be handy in some cases, but in general I think it would rather be an anti-pattern.
First, reusing once again the function parameters parallel, I think that throwing to many exceptions inside a function would be like writing too many parameters inside your function definition. This is commonly accepted that a function taking more that 5 parameters in a row is likely to be a code smell, so the same should be true for exceptions too. This would be the developer responsibility to ensure the code they write is readable, usable, and maintainable.
Then, I think it couldn't be that burdensome, depending on which syntax you are using. If you are using the throws syntax inside the function definition, then you'll have to declare all of your exceptions only once. On the other hand if you are using the excepts keyword, indeed the cost will be multiplied by each time you are calling this function (even thought you will have more control about which exceptions you decide to automatically escalate, but does it worth it?).
An finally, I think that aggregating several exceptions as one would be counterproductive for what we want to achieve in this discussion, because it will make exceptions hidden behind a compound layer, making it less explicit.
So in order to conclude, I'd like to give my point of view about how to explicitly handle exceptions. The more I think about it, and the more I find that the throws syntax inside function definitions would be the most suited solution, because of how similar it can be to function parameters in plenty of aspects (as shown here), and also because of all the others reasons I already discussed in this thread and my PR on the github repo. But of course, I understand that opinions might differ, and that's why I would be pleased to discuss more on that if needed :)
As I'm seeing this evolve, I'm more and more strongly believing this belongs not in JS land, but in things like TypeScript. It really sounds like a type lint rather than an actual language feature here, and even when thinking of it from an implementation perspective, it's just going to add a blue million conditionals the engine has to now optimize out (as it won't know in most cases until function call time whether the errors thrown match the errors declared).
It's much easier to tack on to type checking than it is to implement within JS in light of its dynamic types.
This is a really good point, most stricter exception systems seem to do everything at the compile step of a type-safe language. I can't actually think of any with runtime behavior, but, maybe there's some value in having runtime behavior that these exception systems are missing out on. In both @clemdz's and my proposal, we have a system where exceptions will be auto-escalated when they're not listed. This is a runtime behavior that typescript can't provide, and I find it to be a very useful one too. However, what you bring up seems like an important avenue to explore also.
So here's the kind of exception safety a type-safe system can provide (let's just assume it uses syntax similar to @clemdz's):
It can make sure you don't declare something in "throws", that won't actually get thrown in your function.
It can force you to escalate exceptions by hand that you don't intend to handle or propagate (there's no runtime help with escalation)
This will provide just as much power at solving the current issues as any of the other proposals presented, but it will be a little verbose. Some comparisons:
// "excepts" keyword
function doSomething() {
checkPermissions() excepts PermissionDenied
return getThing() excepts NotFound
}
// "throws" keyword
function doSomething() throws NotFound, PermissionDenied {
checkPermissions()
return getThing()
}
// type-safe example
function doSomething(): any throws NotFound, PermissionDenied {
try {
checkPermissions()
return getThing()
} catch (ex) {
if (ex instanceof NotFound || ex instanceof PermissionDenied) throw ex
throw new Error(ex)
}
}
This type-safe variant is not the greatest. Adding some features to the native Javascript language that would make the code less verbose would certainly help. I don't know what those extra features should be, but whatever they are, they must be useful to non-typesafe language users also.
Another way for the type-safe version to work would be if the compiler actually injected the desired runtime behavior (by adding try-catch blocks around each function body with a throws, and escalating any exception not contained in the throws list), though I believe that kind of thing is against TypeScript's philosophy - TypeScript is just a static type checker.
This is one thing that I really like about your proposal. If what we're trying to do is have a stable function signature, then your proposal makes that function's interface crystal clear.
This is a great point. It looks and feels a lot like Java's checked exceptions, but it does have a lot of important differences, like you noted, that improve upon Java's version.
You really made me think on this one - I really like this parallel. In the end, I think this might be an argument for aggregates.
Functions like this are considered bad. It's a long list of parameters, and you have no idea what each parameter does.
Here I've bundled together related properties into their own object that can be passed around independently. I've also made runQuery take an options object, so it can name all of the other parameters. If these groups of parameters don't need to be passed around independently, I would also accept a single giant options object with lots of named parameters as a valid solution.
The point is, we're still giving just as much information to this function, we've just spent the time to group and organize the parameters, so it's easier to read, understand, and pass around.
This same principle would apply to exceptions. We provide a way to group related exceptions together. e.g. function f() will throw either some IO exception or an InvalidParam exception. If you want to handle a specific IO exception, you can. If you want to handle any IO exception (is that useful?), we could maybe let you. etc.
My one worry is that this system would be misused in the case of a function that throws most IO exceptions, but not xyz. I've seen some proposed exception systems online talk about resolving this scenario by letting you do something along the lines of "function myFunction() throws ...IOExceptions without xyz". But, I don't know how common of an issue this would even be.
I guess this is the big question between our two proposals, isn't it. Is the extra verbosity of declaring exceptions at the call site worth the benefits are being able to handpick which exceptions you want to escalate? Let me see if I can articulate why having the exceptions listed only at the function signature feels a little off to me.
Say we have this contrived example (I'm sure you can find holes in it, but it's the best I can think of for now):
function doSomething(userId) throws NotFound {
const user = getUser(userId)
const systemUser = getUser(systemUserId)
...
}
Now you want to refactor doSomething() and take out the getUser(userId). Instead of passing in a userId, you'll just pass in the user object directly. Now here's the question: Should you drop NotFound from the throws declaration or not? Is it expected that the system user always exists, and the original code authors didn't bother escalating the NotFound exception from getUser(systemUserId)? Or does it make sense to allow users of this function to handle the scenario where the system user does not exist?
Here are the issues that are going on.
The original code authors knew the answers to these questions, they just didn't have a way to articulate it in the code. Every time the author added a function in, they would look at what exceptions it threw and add the ones they felt were relevant to the current function's throws declaration. Each exception in the throws declaration exists because of one or more specific function in the function body, but there's no way to tell which functions are the reason for it, and which functions the code authors assumed would not throw that specific exception, due to how they were being called.
Without auto-escalation behavior, this proposal is no better than adding a JSDoc @throws comment on every function. It's auto-escalation that breaths life into this proposal, and yet, it's implementation is incomplete as shown in this example (it's impossible to auto escalate NotFound on one function call and not the other).
Both of these points make me feel like exception-list declarations fit better at the call site, even though this is unconventional and more verbose.
But, I can also understand that a language feature that incurs too much verbosity just becomes unusable, so maybe "good enough" expressivity is better than "100%" if it cuts down on tons of verbosity.
Who knows, maybe the best solution is a hybrid approach. For example, people use the "throws" declaration, and optionally do something like "getUser(systemUserId) escalate NotFound" to explicitly escalate something that otherwise wouldn't get escalated. This actually doesn't sound too bad ... it cuts down a lot of verbosity while still providing most of the power of my original proposal. The case it does not handle is when one of the functions you're calling decides to start throwing an exception that's already found in your throws declaration. Another way to do a hybrid would be to make the "excepts" clause the required one, and "throws" optional, so the benefit of "throws" in this scenario is some enforced documentation on what gets thrown.
Thanks all of you for this great feedback. I'm loving this discussion, and feel like we're really going somewhere with all of this.
I've been giving this all some more thought, and I've decided that the "throws syntax" plus the optional "escalate" keyword would be good enough.
It's not verbose to use, as is the case with excepts
It gives projects some control over the verbosity level they want to support. They can choose not to use "escalate" anywhere. Or, they can choose to use it everywhere where it's applicable.
I do think "throws" + "escalates" provides a couple more ways for people to trip up, but the verbosity cut will be worth it.
I previously mentioned one scenario that "throws" + "escalates" doesn't cover: If you have a function that throws a couple of exceptions, and you're calling a certain function that just got updated to throw a new exception that happened to be in your throws declaration, then the new exception would not auto-escalate, like we normally would want. However, I've decided this is not a very big concern. Normally when you update a function, you at least go through everything that's currently calling that function to make sure the callers will still work fine with the update.
I'll update the original post soonish to show that this is the current proposal we're actively considering.
And, of course, this is still all open for discussion. For example, maybe you don't like the idea of having an escalate keyword either, and we can discuss that if so.
Instead of updating the original post to explain where the proposal currently stands, I decided to just make a proposal repo, which can be found here. Feel free to provide any feedback about anything (did I explain something poorly? Go into too much detail about something? misrepresent an idea? etc).
I also updated the proposal-comparison repository (here) to contain a folder showcasing the newest iteration of this proposal (folder #5) - it's almost exactly the same as folder #4, but with the occasional "escalates" keyword used.
Finally, I need some ideas about a particularly nasty issue the proposal currently has (my original version had it too, but I didn't want to discuss possible solutions until we were a little more settled on syntax/semantics). The issue is that the proposal currently does not support higher-order functions (including higher-order functions you write yourself, and ones you use from old third-party libraries that won't be updated any time soon). Let me illustrate with a concrete example.
// This higher order function may be part of your codebase, or may
// be part of some third-party library that might not receive
// major updates anymore.
function forEach(array, fn) {
for (const entry of array) {
fn(entry)
}
}
function processUsers(userIds) throws NotFoundEx {
// The notFoundException will get escalated before it reaches this point.
forEach(userIds, userId => {
const user = getUser(userId)
if (!user) throw new NotFoundEx()
...
})
}
Spot the problem? forEach() doesn't declare a throws list, and even if it did, what exceptions should it put in there? forEach() is supposed to be a general-purpose function that can be used anywhere. Because forEach() doesn't declare an exception list, any exceptions thrown in its callback will get escalated. This is actually an issue with Java's exception system also, and I'm not wanting to repeat it here.
So, any ideas? Hopefully, ones that don't require third-party libraries to go through and make changes in order to provide better support for this proposal.
Hegel, an alternative to TypeScript, has support for annotating and tracking exceptions.
function f(): $Throws<TypeError> {
There is some degree of enforcement but generally feels closer to developer hints as it tries to be a sound type system but also aware that almost anything in JS could throw anything at anytime.
The explicit exception handling system should be opt in, so that it does not introduce breaking changes.
If a function does not include the throws keyword in it definition, then it should not be part of that explicit exception handling behavior, i.e. it should not automatically escalate exceptions it has not defined (so in that case every exceptions).
So if a function does not include the throws keyword, it will automatically propagate the exception (default behavior), and an upper function with the throws keyword would still be able to handle it.
The hybrid solution with the escalate keyword could be an interesting compromise, but in your example #5 there is something that does not look right:
function incrementAge(userId, { userIdDoingRequest, noopIfUserNotFound = false }) throws NotFoundEx {
try {
const age = userManager.getProperty(userId, 'age', { userIdDoingRequest })
userManager.setProperty(userId, 'age', age + 1, { userIdDoingRequest }) escalates NotFoundEx
} catch NotFoundEx (ex) {
if (noopIfUserNotFound) return null
throw ex
} catch UnauthorizedEx {
throw new Error(`User with id ${userId} was not authorized to increment another's age. Please make sure they have the appropriate permissions before calling this function.`)
}
}
Here NotFoundEx is present in both the catch block and the escalate syntax, and it's really unclear what to expect of this code. Does the escalate take effect before we reach the catch block, and is the exception already escalated at this point? Or does the escalate keyword take effect after the catch block, and so it depends now on how the error is handled and what is thrown in the catch block? If that's the case, having the escalate keyword written before the catch block would be confusing too.
It escalates before the catch block - right after the function the keyword is attached to is done being called. What's going on in that example is that both getProperty() and setProperty() can throw the same NotFoundEx, but we want it to escalate when setProperty throws it, because it wouldn't make sense if getProperty() is able to find that user, but then, all of a sudden, the user doesn't exist when we call setProperty().
It's not the greatest illustration of the usefulness of "escalates", but it's still a valid place to put it.
@Clemdz - that makes sense to just have the exception propagate normally when no throws keyword is present. I guess this means we need a way of writing throws with an empty list of exceptions, if we want it to escalate everything. Maybe we can make nothing mean something special so you can write throws nothing. Or we use some different nothrow keyword.
This is an interesting idea @lightmare, and I think we're going to need something like this to make higher-order functions work properly, when you want them to participate in this exception system. I'll note a couple issue I can foresee, and I'm not sure how to best deal with them (but maybe these are edge cases that's not important enough to deal with).
This explicit-exception system relies on the ability to auto-escalate exceptions not found in the signature. Allowing a function to declare that it throws "whatever function f throws" would cause us to loose the guarantee that certain exceptions will always escalate. For example:
function provideUser(userId, callback) throws NotFound, *callback {
// May throw NotFound, and maybe some other exceptions that we
// want to auto-escalate, like, ServiceUnavailable.
const user = getUser(userId)
callback(user)
}
The issue is that the exceptions that getUser() will or won't escalate will depend on what callback() throws. If callback() can throw ServiceUnavailable, then getUser() won't escalate ServiceUnavailable anymore.
There's also the issue that the function might not be easy to pull out from the arguments and stick into the signature, like in this scenario:
function runCommand(commands, key) throws CommandNotFound, ??? {
const command = commands.get(key) // throws CommandNotFound
command() // Can throw a number of different things
}
What it feels like we're really trying to do is punch a hole through the throws declaration, making the throws apply to most of the function except at specific locations. e.g. maybe something like this?
// ... indicates the function signature is incomplete, and depends on dynamic logic in the body.
function runCommand(commands, key) throws CommandNotFound, ... {
const command = commands.get(key)
// `throws *` is used to indicate that whatever this function throws will be propagated.
command() throws *
}
This solution will unfortunately make the throws declaration not statically analyzable in some scenarios.
I was just throwing the first idea that came, didn't give it too much though :) But that's not exactly how I envisioned it. Borrowing from your example:
function provideUser(userId, callback) throws NotFound, *callback {
const user = getUser(userId) // anything but NotFound escalates
callback(user) // anything it throws propagates
}
The way I interpret the above is this: throws NotFound does two things:
outside: tells the caller that only NotFound will propagate out
inside: ensures that exceptions other than NotFound are escalated
throws *callback would also do two things:
outside: tells the caller that exceptions coming out of callback will be propagated — it doesn't say what the rest of provideUser itself can or cannot throw.
inside: suppress auto-escalation when calling callback. It should not take all the exceptions callback declares it throws, and suppress escalation in the whole provideUser. No. It should mean, literally, when the actual call to callback(...args) throws something, it will get propagated.
In the second example, runCommand can throw literally anything, there's no point having a throws declaration imo.
@aclaymore - looking at the docs of Hegel, it seems like it does almost as much enforcement as Java. Its type system makes sure you're listing everything a function can throw, and that you're not listing anything that will never be thrown. I believe this falls prey to most of the criticisms against Java's system (except the higher-order function one) that are listed here. The main difference seems to be its type inference, where you can just choose to not declare what a function can throw, and it'll automatically figure it out for you - but if you're going that route, then you've lost exception safety.
Knowing what exactly a function can throw won't help you with exception safety at all. The only thing you need to know is whether it can throw something, or never throws. A testament to this is C++ dropping dynamic exception specification in favour of simple boolean noexcept.
And a testament against that is the fact that it's still considered good practice in Java to label all of the exceptions a particular function might throw, despite all of the downsides their checked exception system has.
It really matters on the level of safety you want. If you feel safe just knowing what throws and what doesn't, then sure, a noexcept keyword is sufficient. But if you are trying to solve the issues outline in the proposal repo here, that Java, Rust, Haskell, etc all solve, then you need something more than noexcept.
With that said ... this C++ transition from a more-exception-safe system to a lesser one is something I didn't know about, and it's interesting. Looking at their original dynamic exception specification feature, it looks like it was implemented almost exactly as this proposal. They didn't do static checks to make sure your "exception specification" was accurate, instead, they did runtime checks, and made it so if an exception came through that wasn't in the specification, the program would crash.
I tried looking around for why this feature was disliked, and I found a snippet on it here. It gave two main reasons:
if a new exception is thrown, you have to update all callers to handle it, otherwise, the program may crash. This one I didn't understand. with or without using the exception specification, your program will crash unless you specifically catch that new exception. If anything, manually updating the exception specification in locations where it matters would help you make sure that you've caught the exception in any important location, thus preventing crashes.
It takes a lot of work to manually update the exception specification in the locations where it matters, whenever you start throwing a new exception. I knew it would take some work, but I felt that it was worth it, as it also made sure that you've dealt with the new exception appropriately in all of the locations where it matters. But maybe I've been underestimating how tedious this is.
Maybe there's some other reason that C++ took out their exception specification feature that doesn't pertain to Javascript, but I haven't seen one yet. And this really worries me ... I don't want to repeat C++'s mistakes, so if C++'s feature really was completely broken, then perhaps we'll have no choice but to weaken the proposal to only having a noexcepts keyword (hopefully not).
I should've clarified that since I'm a C++ guy, for me "exception safety" means "not leaving the system in an inconsistent state when an exception occurs". I would say the title "Explicit exceptions" captures pretty well the idea that this is more about "exception publicity" than "exception safety".
example
class Dataset {
constructor() {
this.data = [];
this.sum = 0;
}
unsafe_add(x) {
this.data.push(x);
// the following line can throw a TypeError,
// leaving the object in an inconsistent state;
// no amount of thrown-exceptions-declarations
// can make this function exception-safe
this.sum += x;
}
safe_add(x) {
this.sum += x;
// this is safe, as long as .push never throws
this.data.push(x);
// in languages where .push can throw out-of-memory
// exception, this is still unsafe
}
}
Without exception specifications, you can catch the new exception higher up in the call stack, not changing any of the calls between the throw and the catch. If it's an exception derived from some class you're already handling, you don't need to update any callers and it won't crash.
Sometimes the appropriate place to deal with an exception is not its immediate caller. Some exceptions are naturally meant to propagate to a boundary that deals with certain kinds of exceptions.
Exception specification kinda hinders your ability to treat individual steps of an implementation as tiny black boxes. If you have a step that internally sorts some array for its own use, and you decide to sort a temporary array of pointers instead of sorting the items in place, suddenly that internal change leaks out of the black box — you now have to declare it throws bad_alloc, although none of its callers can or should handle memory allocation failure, it's supposed to propagate.
Your proposal dodges this issue by limiting the must-be-handled-by-caller treatment to a particular exception class. The callee chooses to throw either auto-propagating, or auto-escalating exception.
Don't focus too much on C++ though, it's a whole different universe. In C++ just knowing whether an operation can throw or not, is extremely valuable and usable information (mainly for the compiler, mind you). But noexcept does not translate to this proposal, it serves a different purpose.
ok ... some things are clicking, and you're starting to make me rethink some things.
Yes, this idea has been more about exception publicity, but naturally, a part of that is "exception safety" as you've defined it, and maybe that's a better definition than what I was using. I can see now how simply knowing if something throws an exception or not would give you enough information to be able to safely keep the system in a good state.
This is a really good point I haven't thought of - the idea that if a caller is catching and handling a super-class of exceptions, then it's already set to handle things if a new exception subclass were added.
I can see this argument - and its really caused me to think. In a noexcepts system, adding a bad_alloc exception deep down wouldn't require intermediate callers to update their call signature - unless they currently do not throw any exceptions at all, in which case they need to remove the noexcepts keyword. This is arguably ok because it's important to ensure, each step of the way that these functions are able to properly clean up after themselves if an exception were thrown.
Now, when you add a bad_alloc deep down, you'll want to make sure that's reflected in your public-API's documentation. So, you would want some way of knowing which public-facing function can currently throw bad_alloc or not, and you would want to know if this changes after the addition of the new bad_alloc exception. An end-user who wants to robustly defend against bad_alloc would want to know exactly which API functions throw it, which ones don't, and if it changes at any point (the API authors could put such changes in the change-logs). I'm talking about public APIs here, but similar principles can apply to sections of a single project too - whatever sections that get used throughout the project should probably have a more stable and well-defined API, and you would want to be careful of its API changes. (I'm just going to start using the term "well-defined API" to refer to any function where we want to make all API information about it as explicit as possible, including good documentation, what exceptions it throws, etc)
Do the intermediate functions care that bad_alloc was thrown? Unless they're declared with "noexcepts", or they want to catch and handle the exception, they wouldn't care. Only functions with a well-defined API care.
So, in the end, we would still need the well-defined functions to explicitly declare what exceptions they throw, so that we have a way of knowing if it changes. And we need some way of verifying if that list of exceptions is correct. I guess with some code-analysis tooling, a build-step could follow different code paths and figure out what exceptions a particular function throws, to verify that the exception list is valid (funny - before I was saying that I was adamantly against this idea, funny how perspective can change). Such tooling would really be something that belongs in a type-safe language.
So, I guess the combination of noexcepts, a way to specify which exceptions a function with a well-defined API throws, and a compile-time way to verify this is all that's needed to satisfy the requirements of this proposal.
I can still see value in putting a throws declaration on all functions, if the user wants to add a little verbosity to make each function a little more well-defined. This "typesafe noexcepts + throws on some functions" version would be able to be used both by people who want "throws" on everything, and people who want "throws" on just a select few functions.