JAVA / EXCEPTIONS
Checked versus unchecked and the philosophy behind them
Classify any Java throwable as checked or unchecked, explain why the compiler treats them differently, and choose the right kind for your own failures.
What you will learn
- A throwable is checked unless it extends RuntimeException or Error
- Explain why the JVM cannot tell a checked exception from an unchecked one
- Choose unchecked for broken preconditions, checked for recoverable I/O failures
- Spot why checked types do not fit lambdas and leak through API layers
Understanding Checked versus unchecked and the philosophy behind them
Everything you can throw in Java descends from Throwable, which splits into Error, Exception, and inside Exception the subtree rooted at RuntimeException. Checked exceptions are defined by exclusion: any Throwable that is neither a RuntimeException nor an Error. For those, and only those, javac applies the handle-or-declare rule at every call site, so the call either sits inside a try with a matching catch or the enclosing method repeats the type in its own throws clause. Nothing else about the two families differs: the bytecode for a throw is identical, and the JVM unwinding the stack has no idea which category it is carrying.
The split encodes a guess about who can do something useful. A checked exception claims the failure comes from the world rather than from the code, such as a deleted file, a dead socket or an interrupted thread, which a correct caller cannot prevent but can often route around by retrying or falling back, so forgetting to consider it was made a compile error. Unchecked exceptions cover the opposite case, a null reference, an index past the end, an argument that was never legal, where no runtime handler repairs the situation and the real fix is editing the program, so demanding a catch at every call would only add noise. Error is a third case reserved for the JVM itself being in trouble, where the honest response is usually to let the thread die instead of catching.
The mechanism has a well known cost, which is why this is a design decision and not a formality. A checked exception is part of your public signature, so it climbs through every intermediate layer, each of which must handle it or advertise it, and advertising IOException from a repository interface leaks the fact that today's implementation happens to touch a disk. Checked types also do not fit functional interfaces such as Function or Supplier, whose methods declare no throws, which is why much modern library code leans unchecked and translates SQLException-style failures into a runtime hierarchy at the boundary. The practical question is not whether a failure is serious, but whether the caller has a decision to make and whether the failure belongs to the abstraction you are publishing.
The distinction is enforced per call site by the compiler, which is also why it disappears the moment a value crosses into reflective or generic code the compiler cannot inspect.
import java.io.IOException;
public class CheckedVsUnchecked {
// The whole rule: a throwable is checked unless it is a RuntimeException
// or an Error. There is no keyword for it, only the class hierarchy.
static boolean isChecked(Class<? extends Throwable> type) {
return !RuntimeException.class.isAssignableFrom(type)
&& !Error.class.isAssignableFrom(type);
}
// The caller cannot prevent this, but it can fall back to a default.
static String loadSetting(String key) throws IOException {
throw new IOException("no config source for " + key);
}
// A broken precondition: only fixing the caller helps.
static int elementAt(int[] data, int index) {
return data[index];
}
public static void main(String[] args) {
Class<?>[] types = {
IOException.class,
InterruptedException.class,
IllegalArgumentException.class,
ArrayIndexOutOfBoundsException.class,
StackOverflowError.class
};
for (Class<?> type : types) {
System.out.println(type.getSimpleName() + " -> checked="
+ isChecked(type.asSubclass(Throwable.class)));
}
try {
loadSetting("port");
} catch (IOException e) {
System.out.println("compiler forced this catch: " + e.getMessage());
}
System.out.println("elementAt -> " + elementAt(new int[] {7, 8}, 1));
}
}
Checked versus unchecked is a compile-time contract about whether the caller is expected to have a decision to make, not a runtime difference in how the throwable behaves.
Worked examples
Only the checked list is a real contract
An override may drop a declared checked exception, yet it can still throw any unchecked one it likes.
import java.io.IOException;
public class OverrideContract {
static class Loader {
String load() throws IOException {
return "from disk";
}
}
static class MemoryLoader extends Loader {
@Override
String load() {
throw new IllegalStateException("cache not warmed");
}
}
public static void main(String[] args) {
Loader loader = new MemoryLoader();
try {
System.out.println(loader.load());
} catch (IOException e) {
System.out.println("io failure: " + e.getMessage());
} catch (IllegalStateException e) {
System.out.println("escaped the declared contract: " + e.getMessage());
}
}
}
Example explained
Line 1MemoryLoader.load drops throws IOException, which is legal because an override may shrink the checked set but never widen it.
Line 2The same override throws IllegalStateException freely, since unchecked types are not part of the signature and the compiler cannot police them.
Line 3The catch for IOException still compiles because the static type Loader declares it, even though this object can never throw it.
Line 4So the checked list is enforced by javac while the unchecked behaviour is, at best, a comment.
Why lambdas force unchecked types
Function.apply declares no throws clause, so only unchecked exceptions can escape a lambda body.
import java.util.List;
import java.util.function.Function;
public class LambdaFriction {
public static void main(String[] args) {
Function<String, Integer> toInt = Integer::parseInt;
for (String raw : List.of("10", "x")) {
try {
System.out.println(raw + " -> " + toInt.apply(raw));
} catch (NumberFormatException e) {
System.out.println(raw + " -> rejected by " + e.getClass().getSimpleName());
}
}
}
}
Example explained
Line 1Function.apply is declared without a throws clause, so any target of that interface may only throw unchecked exceptions.
Line 2Integer::parseInt fits because NumberFormatException extends IllegalArgumentException and therefore RuntimeException.
Line 3A method declared throws IOException could not be used here at all without catching or wrapping inside the lambda.
Line 4The catch sits around apply because the throwable passes through the functional interface untouched, exactly like a checked one would.
Important notes
Catching a checked exception the try block cannot possibly throw is a compile error, while catching an unchecked type always compiles; Exception and Throwable are deliberately exempt from that rule.
There is no keyword or annotation for checked-ness, so swapping Exception for RuntimeException in an extends clause changes your contract with the compiler and nothing else.
Common mistakes
Extending Exception for argument validation, so every caller and every layer above must catch or declare a failure they could have prevented, which is exactly what breeds empty catch blocks.
Writing throws NumberFormatException and assuming callers are now obliged to handle it; on an unchecked type the throws clause is documentation only and the compiler tells nobody.
Crossing the boundary with throw new RuntimeException(e.getMessage()), which drops the original throwable so the failure looks like it started at the wrapper.
Try it yourself
Change, predict, then run
Write a method declared throws Exception whose body just throws one, call it from main with no try or catch, and read the compiler error. Then change only the declared and thrown type to RuntimeException, recompile without touching main, and confirm the same throw now needs no handling.
Open the Java workspaceCheck your understanding
You change a custom exception from extends Exception to extends RuntimeException and recompile every caller without editing them. What is the effect?
- Callers keep compiling: existing catch clauses and throws declarations stay legal, and code that handled nothing now compiles too
- Every catch clause for that type must be removed, because catching an unchecked exception is not allowed
- Existing catch clauses stop matching, because the JVM propagates unchecked exceptions differently
- Nothing compiles, since application classes may not extend RuntimeException
Show answer
Checked-ness only controls whether javac demands handling at the call site, so relaxing it cannot break code that already handled the type; leftover throws clauses simply become redundant. Option 1 is tempting because javac does reject a catch for a checked exception the try block cannot throw, but that rule never applies to unchecked types, which are always catchable.