An alternative to checked exceptions (that actually works for catching bugs)

As you all know, checked exceptions are… bad. That’s why Kotlin doesn’t have them! But sometimes in application logic, you run into bugs where, due to a mistake, some code that’s not supposed to fail throws an exception of the same type as your API is documented to throw - and more importantly, API consumers catch that exception and treat it as a non-critical error. So you end up swallowing a critical exception and being hella confused about why the code silently doesn’t work.

How can one improve the situation? One idea would be to define functions like so:

fun foo(): Bar try IllegalArgumentException {
    ...
}

(The use of try here is intentional. These aren’t checked exceptions, but instead act a lot more like a try-catch around the whole function.)

In this case, any IllegalArgumentException thrown through the function would actually be caught by the function and wrapped in an RuntimeException, unless it were to be explicitly (re-)thrown in the function body itself.

fun foo(): Bar try IllegalArgumentException {
    throw IllegalArgumentException() // throws IllegalArgumentException
}

fun foo(): Bar try IllegalArgumentException {
    run { throw IllegalArgumentException() } // throws RuntimeException
}

fun foo(): Bar try IllegalArgumentException {
    try {
        run { throw IllegalArgumentException() } // throws IllegalArgumentException
    } catch (e: IllegalArgumentException) { // caught by this
        throw e // re-thrown, so it actually retains its IllegalArgumentException-ness
    }
}

Yes, this is fairly verbose in the rethrowing case. It might be helpful to add syntax for simplified rethrowing, if this catches on. And it might be argued that lambdas should count as being part of the same function, such that the last 2 examples would be invalid. But, as an idea, how does this look?

I don’t really get it. How is it possible that you accidentally throw an exception that is an exception of some API? Usually, if APIs have some exceptional states, they define their own exception types, so it is hard to throw them by a mistake.

Also, what is the point of wrapping IllegalArgumentException in RuntimeException? It is RuntimeException already.

If you mean that your function may throw IllegalArgumentException and users of this function catch this exception, then… well, they should probably never do this. Runtime exceptions are mostly for bugs in the code and not for failure states expected by the application logic. If you catch IllegalArgumentException then you either use some badly designed library, so you don’t have any other choice or you just do something wrong.

1 Like

Exceptions have non-local reasoning. This proposal makes that reasoning much more local, without introducing checked exceptions.

Real code that runs into these issues is usually too complex to summarize in a suggestion post. The real code that prompted this suggestion isn’t even in Kotlin, but in Python. (The Kotlin code we write attempts to avoid exceptions as much as possible, whereas Python generally tends to be very exception-heavy, so it is true that Kotlin doesn’t suffer as much from the issue. Nevertheless, this suggestion still seems like a potential good fit for Kotlin.)

I can’t understand why these 2 would behave differently, run is even an inline function.

Inline functions aren’t magic. They’re still closures.

It’s arguable that they should be the same because it’s a closure, however, such that even non-inline functions would throw the IllegalArgumentException, as long as there was a closure involved.

Anyway, something like:

fun bar() try IllegalArgumentException {
  fun foo() {
    throw IllegalArgumentException()
  }
  foo()
}

wouldn’t throw IllegalArgumentException but RuntimeException.

Maybe I didn’t understand the proposal, are you suggesting that the behaviour is different whether the function directly throws as opposed to calling a function that throws?

This seems a very refactor hostile feature, I move some code in a helper function and all of a sudden my exceptions get wrapped.

3 Likes

At least your exceptions won’t get silently swallowed (as happens with checked exceptions).

Then you can add the explicit rethrow where you need it.

You can also just not use the feature. It doesn’t affect the caller at all and whatnot.

Well, I do not agree. I would love to know about possible contingencies of an API, so I can decide if I can handle that problem in my code or that I should convert it to a fault. See Effective Java Exceptions.

The problems in the design of the exception hierarchy and a lot of Java code were:

  • Runtime exceptions are a type of checked exception that is not checked.
  • Lots of APIs used contingencies (checked) instead of faults (runtime). For example: persistence APIs. Most of the time, the client code has no other options but to throw an exception.
  • Code kept throwing contingencies of all lower layers, instead of converting them into faults as soon as client code was unable to sensibly mitigate them. This way implementation details were spread through the whole codebase.

Exception handling in Java was designed pretty well, but if the design was better I think checked exceptions could have been a very valuable language construct.

What if I could easily change any contingency I cannot handle to a fault with support of the language? I know this syntax does not make a lot of sense, but it is just an example:

try {
   <do some I/O>
} catch (e: FileNotFoundException) {
   <handle it properly>
// This will convert any contingency not explicitly caught into
// a fault (by wrapping it with a runtime exception).
} fault(<optional message>)

Until fault(...) is added the compiler can still warn you about unhandled checked exceptions. And the IDE can easily tell you which checked exceptions will be converted to a runtime exception by the last line.

Isn’t it essentially introducing bugs to the code that will need to be debugged later?

Unchecked exceptions are for very unexpected situations or critical failures that can’t really be handled in any specific way and aren’t part of the expected application states. They are: bugs in the code (NPEs, divisions by zero, getting out of array bounds, stack overflows, etc.), hardware/resource failures (out of memory, out of disk space, etc.) and so on.

Assume we have a function and one of its expected outcomes is some contingency. It is described in the docs, it is expected to happen by the application logic. Now, we invoke this function, but we don’t handle this contingency in any way. That means our function is also expected to have some contingency - this should also be documented and expected by the application logic. By re-throwing as unchecked we basically hide this contingency from the caller.

There are some cases that justify re-throwing checked as unchecked. For example, sometimes we feel like some checked exception should be defined as unchecked in the first place (IOException). Sometimes, we believe we fulfilled API requirements, so it should never throw a specific checked exception (for example, we already verified that user is in database, so we ignore UserDoesNotExist). But these are rather rare cases. In most cases, I consider re-throwing checked as unchecked as a bad thing.

Having said that, I don’t consider myself a master in the field of Java exception handling. It may be a matter of one’s individual opinion or I may be entirely wrong. I’m open to discussion (if this is not considered off topic in this thread).

This is converting it to a fault. But it doesn’t rely on abstract concepts like checked exceptions - it lets you do it with any exceptions. It also doesn’t force any requirements on your caller.

It makes you responsible for handling it, wrapping it, or converting it to a fault. Not your caller.

Of course you may have missed an exception that you want to handle instead of faulting. There should be a fault barrier that will log any exception that is not caught by any code. If you find one there that you want to handle, you update your code. It is very difficult to determine all exceptions that your code wants to handle upfront.

I see them as 2 distinct types:

  • Programming errors. It is possible to prevent them by adding a bit of code. Note that in a lot of causes, the code will probably have to fault at some point anyway: you receive a null, but can only produce a valid result if you have an object? That is an illegal argument or state, and the only thing you can do then is fault.
  • Faults. Events and states in the environment that you cannot control, and for which there is no useful recovery.

If the checked exception is part of the domain of the calling function, not catching or rethrowing it is fine. If the checked exception is an implementation detail of the calling function, it must be converted to a fault.

It is fine that an I/O exception is checked. Because when I am dealing with the I/O domain, I want to handle I/O contingencies.

But a lot of the time the implementation uses another domain than the domain being implemented. The implementation probably wants to handle contingencies of the domain used. But if there is no useful way to recover, it can either do nothing (if no result is expected), convert it to a contingency of the domain (= wrap it with a checked exception), or convert the exception to a fault (= wrap it with an unchecked exception).

It indeed is a lot of opinion. I am of the opinion that checked exceptions are useful, but that for them to work well there are some requirements: easy way to convert to a fault, sane exception hierarchy with multiple base types, etc.

1 Like

I am not 100% sure what you mean, but hopefully this example makes things a bit clearer:

  • Function 1 in domain A (A.1) uses function 2 in domain A (A.2) for its implementation. A.1 calls A.2, and A.2 throws a contingency in domain A. A.1 is allowed to not catch or rethrow this exception, because A.1 also is part of domain A. But A.1 is of course also allowed to recover from the exception, or convert it to a fault.
  • B.1 uses C.1. B.1 calls C.1, and C.1 throws a contingency in domain C. Now B.1 can decide to:
    • Convert the exception to a contingency of domain B (= wrap it in a checked exception).
    • Convert the exception to a fault (= wrap it in an unchecked exception).
    • Handle the exception.

In general faults should not never be caught, except by the fault barrier.

The above can be done in Kotlin, but the compiler and IDE do not help me because they do not tell me I did not handle all contingencies.

I’m surprised I haven’t seen the Either monad brought up here yet. I think the cleanest solution (including a migration plan detailing how to get where we are today to our ideal final state) would be:

  1. Add Either to the standard Kotlin library.
  2. Add syntactic sugar to make Either very easy to work with within Kotlin.
  3. Add syntactic sugar to make Either very easy to interoperate with the various backends where appropriate (E.g. nice easy (automatic?) conversion to and from JVM exceptions, similar for Javascript, no-op for native, etc.)
  4. Make lots of blog posts and youtube tutorials encouraging people to use Either instead of Exception (in the same way that we today encourage people to use coroutines instead of threads).
  5. Deprecate exceptions in Kotlin altogether – exceptions can still exist as an implementation detail in a given backend (e.g. in on the JVM), but are hidden as a Kotlin language concept. For example, extend the “platform types” capability, which today allows us to say we’re not sure whether or not an API guarantees it return a non-null value, to also allow us to express we’re not sure whether or not an API guarantees no exceptions thrown.
1 Like

Not necessarily Either as much as a Result construct. I use this with much success in my codebase and as such avoid exception handling altogether (except catching lower level APIs that still throw those)

1 Like

Yes, Either and Result are essentially isomorphic to each other, and I feel like more people have “heard of” Either which is why I usually choose to mention that one, but either one would work.

1 Like