Checked vs unchecked

The hierarchy, the original intent, why most modern code prefers unchecked, and the rule that decides it for you.

4 min read🧰 Exceptions, I/O and Reflection

Java is the only mainstream language with checked exceptions, and the debate about whether that was a good idea has run for twenty-five years. You do not need to settle it. You need to know the hierarchy, what each half was intended for, and a rule that tells you which to throw — because the wrong choice spreads throws clauses through code that cannot do anything with them, or hides a failure the caller needed to see.

The hierarchy

plaintext
Throwable
 ├─ Error                      JVM-level: OutOfMemoryError, StackOverflowError — do not catch
 └─ Exception
     ├─ RuntimeException       UNCHECKED: NullPointerException, IllegalArgumentException,
     │                          IllegalStateException, UnsupportedOperationException ...
     └─ (everything else)      CHECKED: IOException, SQLException, InterruptedException ...

A checked exception must be declared in the method's throws clause or caught; the compiler enforces it. An unchecked exception (a RuntimeException or Error) propagates silently. Both unwind the stack the same way at run time; the difference is entirely in what the compiler makes you write.

The original intent

Checked: recoverable conditions the caller should plan for — the file is not there, the connection dropped. Unchecked: programming errors — a null where none was allowed, an index out of range, a state the object was never supposed to be in. The idea was that a caller can do something about a missing file but nothing about a bug.

Why modern code prefers unchecked

In practice, most callers of readConfig() throws IOException cannot do anything about the IOException either. So they wrap it, or declare it, and the throws climbs the call stack through layers that have no interest in files. Lambdas made it worse: Function cannot throw checked exceptions, so every stream over I/O needs a wrapper. Spring wraps SQLException into unchecked DataAccessException for exactly this reason; Hibernate, Jackson and most modern libraries throw unchecked.

The cost of unchecked is that the compiler no longer tells a caller what can fail. Documentation and types (Optional, a sealed Result) take over that job.

The rule

Throw unchecked, unless the immediate caller can reasonably recover and you want the compiler to force them to think about it.

That second case is rare and real: InterruptedException (the caller must restore the interrupt flag or stop), a parse failure in a library whose whole purpose is parsing, a retryable I/O failure at the one boundary that retries. Everywhere else — validation, not-found, illegal state, wrapped infrastructure failures — throw a RuntimeException subclass with a good message.

Standard exceptions to reuse

SituationThrow
Bad argumentIllegalArgumentException
Null argumentNullPointerException via Objects.requireNonNull
Object in wrong state for this callIllegalStateException
Operation not supported hereUnsupportedOperationException
Index problemsIndexOutOfBoundsException
I/O wrapped as uncheckedUncheckedIOException

Use these before inventing your own; a reader knows what they mean.

Catching

  • Catch the narrowest type that you can actually handle. catch (Exception e) at a service layer swallows NullPointerExceptions that were bugs.
  • Never catch and ignore. An empty catch block is a bug silencer. At minimum log with the exception object (not just its message), and say why it is safe to continue.
  • Wrap, do not replace. throw new ServiceException("could not load order " + id, e) keeps the cause. throw new ServiceException("failed") throws away the stack trace that would have explained it.
  • Do not catch Error, except at a top-level boundary to log and die.
  • InterruptedException: either rethrow, or Thread.currentThread().interrupt() before handling. Swallowing it makes the thread un-cancellable.

Multi-catch and rethrow

java
try {
    parse(input);
} catch (NumberFormatException | DateTimeParseException e) {
    throw new ValidationException("bad input: " + input, e);
}

One handler for several types with no common useful supertype. A rethrown exception in a catch (Exception e) block keeps its precise checked type for the throws clause (precise rethrow, Java 7).

Exceptions are not control flow

Throwing is expensive — filling in the stack trace walks every frame — and a method that throws on an expected outcome ("no user with that email" on a login form) is slow and lies about what happened. Return Optional, a boolean, or a sealed result for outcomes that are part of the normal path. Throw for things that should not happen or that the caller cannot continue past.

Progress is saved on this device and to your account when signed in.