The Case for Checked Exceptions in Java
Checked Exceptions
What is the use case for checked exceptions in Java? The Oracle Java documentation states.
"If a client can reasonably be expected to recover from an exception, make it a checked exception." Oracle Java documentation
Use checked exceptions for something recoverable, like a file not being found. Don't use them for programming errors such as index-out-of range errors. Java is on the right track by having two different exception types instead of trying to lump two concepts into one.
Compile Time Checking
The compiler is the best tool. Anything that the compiler can do, you should take advantage of it. The Java compiler ensures that checked exceptions are handled by the developer. If a method has a throws clause, meaning it throws a checked exception, the Java compiler will check two things, and if one of those two isn't happening, you will get a compiler error.
The two ways to handle a checked exception are either by putting it in a try block like the following:
public void doSomething() {
try {
methodThatThrowsIOException();
} catch (final IOException e) {
// handle exception ...
}
}
Or you can add a throws clause to the method if you need to handle it somewhere else.
public void doSomething() throws IOException {
methodThatThrowsIOException();
}
Self Documenting
In several programming languages, you rely on the method's documentation for what exceptions are thrown. If the documentation isn't provided, you have to look through the code or wait until you have a case where it throws an exception.
Checked exceptions solve the problem of knowing what exceptions a method throws. Given the following method signature:
public String read(final File file) throws FileNotFoundException;
The signature is very clear. This method takes a File parameter, returns a String of the contents, and can throw a FileNotFoundException. Checked exceptions document with code what exceptions are thrown.
Understand the Benefits and Drawbacks of a Tool
Checked exceptions are a tool. It is important with any tool that you understand how that tool is supposed to be used. If you take a step back and look at where checked exceptions shine, think about it like this: A method throws an exception. You can either handle it then or pass it up the stack by adding it to the method signature. This is the common flow for handling errors in most programming languages. Checked exceptions require you to think about the exceptions in your code.
Sometimes checked exceptions are blamed when the problem is bad code. One example would be calls that are too deep when they should be more shallow. Another example could be that the exceptions are too generic. For example, SQLException is really generic. What would it look like if there were more types instead of describing the error with a message. Using checked exceptions can be very bad when they are used improperly or when you should be throwing a RuntimeException instead.
Java supports adding checked exceptions to interfaces. This is a clear drawback. Just because this is legal doesn't mean you have to use it and should be avoided unless you absolutely can't. Defining a checked exception on an interface exposes an implementation detail that the implementer may or may not have. This can really clutter code and force a developer to handle an exception that doesn't even exist.
Exceptions in Other Programming Languages
Java isn't the only programming language that has checked exceptions. The Swift programming language has checked exceptions. The implementation is similar to Java, but the good parts. Swift forces you to handle an exception or add a throws clause to the method, just like Java. There is more to it than this in Swift, but these are the basics.
In languages such as Rust, errors are handled through a Result type. This follows the same flow as above, where you either handle the error when the method is called or pass it up the stack by returning a Result type. An error in Rust can be any type you want to represent that error.
Swift's try!
You can wrap a checked exception in a RuntimeException. This converts the checked exception into an unchecked exception.
try {
methodThatThrowsIOException();
} catch (final IOException e) {
throw new RuntimeException(e);
}
This is something that can be easily abused. This is good when you have a method that throws an exception, but you know it will never throw that exception. An example of this would be loading an image that ships with your application. The Swift programming language has a feature built into it for this very use case. You can use try! and it will disable the checked exception.
// Swift code
let logo = try! load("logo.gif")
If the method does end up throwing an exception, your application will crash. This is actually pretty easy to implement in Java.
// Define a new type for a fatal error
public final class FatalException extends RuntimeException {
public FatalException(final Exception e) {
super(e);
}
}
// Define the interface that has a checked exception
@FunctionalInterface
public interface SupplierException<T> {
T get() throws Exception;
}
// Implement the method to disable the checked exception
public static <T> T doTry(final SupplierException<T> supplier) {
try {
return supplier.get();
} catch (final Exception e) {
throw new FatalException(e);
}
}
// Using it
final var logo = doTry(
() -> load("logo.gif")
);
Conclusion
You can see how other programming languages may not have the same implementation as Java but still provide the same flow. This flow handling errors is pretty common. Misusing any tool can turn it into a bad one. It is up to your development team to decide how you want to use this feature and what you want to avoid.