Why You Should Avoid Using Checked Exceptions in Java
Published on September 12, 2023

I gave a short talk about this at Devoxx UK 2023. Check it out on YouTube.

Introduction

Most mainstream programming languages have dedicated constructs for dealing with exceptions. What are exceptions? These are instances of abnormal behaviour as a result of uncommon input or system state. Exception-handling mechanisms break the typical program's execution flow, as noted on Wikipedia. You are probably familiar with the try-catch-finally construct if you use an object-oriented programming language like C# or Java. However, you may not be familiar with the notion of checked exceptions. I recently learnt that this "feature" is basically exclusive to Java - at least when it comes to mainstream programming languages. In this article, I hope to convince you why checked exceptions are bad and, if you are not a Java developer, why you should be grateful that your language does not have them!

Difference Between Checked and Unchecked Exceptions

The idea of checked exceptions is to enforce explicit exception handling at compile-time in an attempt to ensure "completeness" of the code. A method which produces a checked exception can throw that exception, which becomes part of the method signature. Any callers of that method must then either propagate the exception by also including it in their method signature or deal with it using a catch block. To demonstrate, consider the following code:

public class ExceptionsDemo {

    public static void main(String[] args) throws Throwable {
        code();
    }

    static class BaseChecked extends Exception {}
    static class CheckedA extends BaseChecked {}
    static class CheckedB extends BaseChecked {}

    static class BaseUnchecked extends RuntimeException {}
    static class UncheckedA extends BaseUnchecked {}
    static class UncheckedB extends BaseUnchecked {}

    interface MyApi {
        void checked() throws CheckedA, CheckedB;
        void unchecked();
    }

    static class MyApiImpl implements MyApi {
        @Override
        public void checked() throws CheckedA {
            throw new CheckedA();
        }

        @Override
        public void unchecked() {
            throw (Math.random() > 0.5) ? new UncheckedA() : new UncheckedB();
        }
    }

    static void code() {
        MyApi api = new MyApiImpl();
        api.unchecked();
    }
}

Here we have two exception hierarchies: BaseChecked and BaseUnchecked, each of which has two subclasses. Notice how BaseChecked extends from Exception, whilst BaseUnchecked extends RuntimeException. To illustrate the difference, consider the MyApi interface, which declares two methods. The checked() method declares in its signature that an implementing method may throw either a CheckedA or CheckedB exception, whilst unchecked() declares nothing. When implementing the interface, it is possible to throw any unchecked exception from the unchecked() method. That is essentially what an unchecked exception is: an exception which is a subclass of java.lang.RuntimeException (either directly or indirectly).

By contrast, checked exceptions are subclasses of java.lang.Exception. The compiler requires checked exceptions to be declared in the method signature for them to be thrown. However, notice that although the MyApi::checked interface method declares CheckedA and CheckedB as possible exceptions, the implementation (MyApiImpl::checked) only throws CheckedA and does not need to declare CheckedB. Thus, overridden methods need not declare their parent's exceptions in their own signature unless they are thrown from the method. However, new checked exceptions which are not declared by the super method cannot be thrown as this would violate the polymorphic principles that the language relies on.

Quirky Semantics

The semantics become easier to understand with the aid of a compiler, so if you are unfamiliar with exceptions in Java, I encourage you to play around with the code in your IDE. For example, notice how the compiler forces you to handle both CheckedA and CheckedB if you change the call from api.unchecked() to api.checked() in the following example, This is because you're calling the interface method, not the implementation one.

static void code() {
        MyApi api = new MyApiImpl();
        try {
            api.checked();
        }
        catch (CheckedA | CheckedB ex) {
            ex.printStackTrace();
        }
    }

You could of course throw it instead:

static void code() throws CheckedA, CheckedB {
    MyApi api = new MyApiImpl();
    api.checked();
}

or handle one exception and throw the other:

static void code() throws CheckedB {
    MyApi api = new MyApiImpl();
    try {
        api.checked();
    }
    catch (CheckedA ax) {
        // TODO handle here
    }
}

You could also "future-proof" your code for maximum compatibility by declaring BaseChecked in the method signature:

static void code() throws BaseChecked {
    MyApi api = new MyApiImpl();
    api.checked();
}

If you don't want to pollute your method signature, you can wrap it in a RuntimeException:

static void code() {
    MyApi api = new MyApiImpl();
    try {
        api.checked();
    }
    catch (BaseChecked ex) {
        throw new RuntimeException(ex);
    }
}

Note that you can still catch it in the calling code, but you will have to call getCause() to get the original exception that was thrown, like so:

public static void main(String[] args) throws Throwable {
    try {
        code();
    }
    catch (Exception ex) {
        assert ex.getCause() instanceof BaseChecked;
    }
}

Sneaky Throw

There's also a hidden trick I learnt about when researching this topic, it surprised me I first encountered it. Did you know that Java allows you to effectively bypass checked exceptions? Before I get into it, you first need to know that RuntimeException actually extends Exception, despite the former being unchecked and the latter being checked! The compiler checks the class hierarchy and makes an explicit exception (pardon the pun) for RuntimeException and its subclasses. Here's the trick feature

public static <e extends="" throwable=""> void sneakyThrow(Exception ex) throws E {
    throw (E) ex;
}

static void code() {
    MyApi api = new MyApiImpl();
    try {
        api.checked();
    }
    catch (BaseChecked ex) {
        sneakyThrows(ex);
    }
}
</e>

Notice how we can call the api.checked() method, and effectively throw the checked exception without declaring it in the method signature of code()! And no, this isn't some syntax sugar for wrapping an exception. If you were to catch it in the caller, you will find that it's the same exception that was thrown, not a RuntimeException which wraps it.

public static void main(String[] args) throws Throwable {
    try {
        code();
    }
    catch (Exception ex) {
        System.out.println(ex.getClass().getName());
    }
}

Shadowing Exception Subtypes

If the previous section blew your mind, then I have partially succeeded in making my point: checked exceptions are complicated! Especially in a language which supports both checked and unchecked exceptions, the semantics and interplay between them can be quite complex and jarring at times. This is further exacerbated by the fact that checked exceptions are the "default" in Java. This creates a problem with "shadowing" of unchecked exceptions. Consider for instance the following code:

static void code() throws Exception {
    MyApi api = new MyApiImpl();
    if (Math.random() > 0.67) {
        api.checked();
    }
    else {
        api.unchecked();
    }
}

Notice how the method signature now declares the general java.lang.Exception in its throws clause. If we call the method, we must now handle it. But what happens when instead of throwing BaseChecked, a BaseUnchecked exception is thrown? What if we don't want to catch RuntimeException? Well, then we have to rethrow it. The idiomatic way is to catch RuntimeException first, like so:

public static void main(String[] args) throws Throwable {
    try {
        code();
    }
    catch (RuntimeException ex) {
        throw ex;
    }
    catch (Exception ex) {
        // Handle checked
    }
}

Similarly, if you wanted to handle CheckedA, CheckedB and BaseUnchecked explicitly but not RuntimeException, you can do so like this:

public static void main(String[] args) throws Throwable {
    try {
        code();
    }
    catch (BaseUnchecked ex) {
        // Handle BaseUnchecked
    }
    catch (RuntimeException ex) {
        throw ex;
    }
    catch (Exception ex) {
        if (ex instanceof CheckedA) {
            // Handle CheckedA
        }
        else if (ex instanceof CheckedB) {
            // Handle CheckedB
        }
    }
}

Thus, you catch exceptions from most specific to most generic, almost like a switch statement. Do you see the problem? The more generic an exception, the more information it hides. You are forced to deal with the most generic type of exception, and it is up to you to decipher what specific subtypes may be thrown by a method. This cannot be communicated through method signatures - the compiler can't help you here. So, you must rely on documentation or even knowledge of the source code to decipher what possible exceptions a method may throw. This arguably defeats the main purpose of checked exceptions. Granted, improper use of exceptions in method signatures is a design problem more than a language one, but it only takes one bad apple to corrupt your method signatures.

In practice, this is especially common with java.io.IOException, where there are many subclasses which describe specific problems, but if you are using a library method which throws IOException, then you have no effectively forgone the ability to throw more specific subtype unless you are willing to explicitly catch and handle all other possible IO exceptions, which I would not recommend!

API Evolution and Leaky Abstraction

By definition, checked exceptions must be declared in a method signature to be thrown. This means that if your implementation changes - say, you call a library method which throws an IOException or other checked exception - you are forced to either handle it within your implementation or wrap it in a RuntimeException. The former option adds bloat to your code and makes the method harder to comprehend. The latter is a workaround, however, it is not necessarily a "breaking change", because your method does not need to declare all possible unchecked exceptions. The best way to communicate such a change is to use the @throws Javadoc in the method signature documentation, like so:

/**
 * Method that does X.
 * 
 * @throws UncheckedA If A goes wrong.
 * @throws UncheckedB If B goes wrong.
 */
static void code() {
    new MyApiImpl().unchecked();
}

This way, users of your method do not have to go on an expedition to discover when a potential exception may be thrown. However, it does put an extra burden on maintainers to document explicitly the exceptions which may be thrown, and for users to read the documentation. You are explicit about the exceptions that you are aware of in this method and when they may be thrown. This is arguably more communicative than simply throwing a checked exception and relying on the compiler to force your users to handle it. Besides, this way you can be selective about what information you expose to your users. You do not have to declare low-level details of exceptions which MAY be thrown but are highly unlikely or even impossible. This brings me to my next point.

Some Exceptions Can NEVER Happen

In some cases, checked exceptions must be caught even when they can proveably never be thrown. Here's a trivial case:

static void code() throws URISyntaxException {
    URI url = new URI("https://example.com");
}

Most strings are valid URIs, since java.net.URI supports partial URIs and can determine which segments are specified. Recognising this, the JDK maintainers have an alternate way to create a URI. The [documentation] for this method(https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/net/URI.html#create(java.lang.String)) should give you an indication of why it exists.

static void code() {
    URI url = URI.create("example");
}

Heavy-handed use of checked exceptions can make a library unpleasant to work with and even worse for your metrics. A particularly annoying example from personal experience is Jackson's ObjectMapper. In the Vonage Java SDK, most of our domain objects need to be serialised to JSON, but doing so using Jackson requires us to handle JsonProcessingException. The only way this exception can be thrown is if the class has incorrect use of annotations. There is even a popular StackOverflow question about this because when chasing 100% code coverage it is required to cover the catch block, even though in practice the exception will never be thrown.

Propagation By Default

Most of the time, you probably want to propagate (“bubble up”) an exception anyway. The beauty of unchecked exceptions is that handling them is optional. By default, they are thrown until they are caught, or handled by the executing thread's UncaughtExceptionHandler (the default behaviour is to print the thread's stack trace). With checked exceptions, you have to explicitly propagate, which as discussed pollutes method signatures and makes it the caller's problem, even when in practice the exception may never occur.

Falling Out Of Favour

There is a good reason why Java is one of the only widely used programming languages that has checked exceptions (refer to this StackOverflow question). Other JVM languages like Kotlin, Scala, Groovy, Clojure etc. do not have checked exceptions - at least, they don't force you to handle them. The Kotlin language documentation summarises the reasoning quite well, and cites the 2003 interview with lead language designer of C# on the rationale for skipping checked exceptions.

Even within the Java language itself, you only need to look at newer APIs in the standard library to realise that checked exceptions should be used sparingly. APIs introduced in Java 8 such as java.time, java.util.stream and java.util.function all avoid checked exceptions. The now well-established Streams API is actively hostile to checked exceptions. This has caused a lot of pain for developers who want to use the streams API because it requires hacky workarounds to propagate checked exceptions from within Java's built-in functional interfaces. More on this later.

Other Exception Handling Paradigms

My curiosity about checked exceptions began with a discussion with my colleague, Guillaume. He has given an excellent talk on this subject, which steelmans the case for checked exceptions. He argues that exceptions are not always the right tool for dealing with errors and advocates for using monads instead. An example of this is the way Java Streams API deals with exceptions. Take for example the following code:

static void code() {
    OptionalInt resultWrapped = IntStream.range(1, 20)
            .filter(i -> i % 9 == 0 && i % 2 == 0)
            .findAny();
    int guaranteedResult = resultWrapped.orElseThrow(IllegalStateException::new);
    int resultWithAlternative = resultWrapped.orElse(18);
}

Notice how doing a filter on a stream means that when you call a terminal operation like finayAny(), you are presented with a wrapper which may or may not contain a result. To obtain the result, you have to call orElseThrow(), which can also take a Supplier to customise the exception if the value is absent. This is almost like a checked exception in disguise because you are forced to explicitly acknowledge the potential absence of a value, even if in practice it is guaranteed to be present. Of course, it is arguably more elegant and explicit, since you also have the option of providing an alternative value with orElse.

However, Venkat Subramaniam's Devoxx UK 2023 talk on "Exception Handling in Functional and Reactive Programming" made me realise the two views are not conflicting. Two direct quotes from his talk for context are:

"Exception handling is purely an imperative style of programming." "Functional programming and exception handling are mutually exclusive."

I would highly recommend the talk, which also touches on the issue of using checked exceptions within functional code, such as Java Streams as previously discussed. The takeaway is that in functional and reactive styles of programming, exception handling is performed throughout the pipeline of data transformation. Rather than using catch and finally blocks, reactive frameworks use explicit error-handling functions applied to the pipeline for dealing with exceptions. And this brings us full circle to the original intention of checked exceptions in the first place: to ensure completeness and explicit acknowledgement of errors & failures within the code. Perhaps the debate around exception handling is really about when and where in the code we should handle errors which may arise, and less on the mechanisms used to accomplish this.

Signing off

That's all for now! If you have any comments or suggestions, feel free to reach out to us on X, formerly known as Twitter or drop by our Community Slack. I hope this article has been useful and I welcome any thoughts/opinions. If you enjoyed it, please check out my other Java articles.

Sina MadaniJava Developer Advocate

Sina is a Java Developer Advocate at Vonage. He comes from an academic background and is generally curious about anything related to cars, computers, programming, technology and human nature. In his spare time, he can be found walking or playing competitive video games.

Ready to start building?

Experience seamless connectivity, real-time messaging, and crystal-clear voice and video calls-all at your fingertips.