exceptions
items 69–77: when to throw, what to throw, and never ignoring them.
- chapter
- chapter 16.09
- reading time
- 10 min read
exceptions are for exceptional conditions, not for control flow. items 69 to 77: throw checked exceptions for things a caller can recover from and unchecked ones for bugs, reuse the standard types, translate low-level exceptions at layer boundaries, put the failing values in the message, leave objects unchanged when you fail, and never swallow one silently.
- item 69exceptions aren't control flow
- item 70checked for recoverable, unchecked for bugs
- item 71don't force callers to catch what they can't handlesealed result types
- item 72reuse the standard exceptions
- item 73translate exceptions at layer boundaries
- item 74document every exception you throw
- item 75put the failing values in the messagehelpful NPE messages
- item 76fail without half-changing state
- item 77never swallow an exceptioncatch (E _)
_ changed three of them.when to throw
item 69 · exceptions aren't control flow
an exception is slow to create (it captures the whole stack trace), the JIT optimizes the normal path rather than the catch block, and code that catches to steer itself hides real errors in the same catch. if callers need to ask "will this work?", give them a way to ask: a state-testing method (hasNext() before next()), or a return value that can say "no" (Optional, a result type).
about 40x, and almost all of it is building 500,000 stack traces nobody reads. the same rule applies to API design: don't make your users use exceptions for ordinary outcomes.
item 70 · checked for recoverable, unchecked for bugs
java has three kinds of throwable, and each one is a message to the caller:
- ├── the JVM is in trouble. don't catch, don't subclass
- │ ├── OutOfMemoryError
- │ └── StackOverflowError
- └── checked: the compiler makes callers handle it
- ├──
- │ └── FileNotFoundException
- ├── InterruptedException
- └── unchecked: precondition violated, a bug
- ├── IllegalArgumentException
- ├── IllegalStateException
- ├── NullPointerException
- └── IndexOutOfBoundsException
use a checked exception when the caller can reasonably recover (file missing, network down, payment declined) and give it methods with the details the caller needs to recover (how much money was missing?). use a RuntimeException subclass for precondition violations: the fix is to change the code, not to catch. never throw Error, and never subclass Throwable directly. the mechanics are in exceptions.
item 71 · don't force callers to catch what they can't handle
a checked exception is a cost on every caller: a try block, and no way to use the method in a stream or lambda. it's only worth it if the caller can actually do something useful. if the best they can do is throw new AssertionError() or log and give up, make it unchecked, or return something that represents "no result".
sealed interface Parsed permits Ok, Bad {}
record Ok(int value) implements Parsed {}
record Bad(String input, String why) implements Parsed {}
static Parsed parsePort(String s) {
if (!s.matches("\\d{1,5}")) return new Bad(s, "not a number");
int p = Integer.parseInt(s);
return p <= 65_535 ? new Ok(p) : new Bad(s, "too big");
}
void main() {
for (String s : List.of("8080", "http", "70000")) {
String msg = switch (parsePort(s)) { // exhaustive: forget Bad and it won't compile
case Ok(int v) -> "port " + v;
case Bad(String in, String why) -> in + ": " + why;
};
IO.println(msg);
}
}port 8080 http: not a number 70000: too big
item 72 · reuse the standard exceptions
everyone already knows what these mean, so your API is easier to learn and your stack traces easier to read:
| exception | throw it when |
|---|---|
IllegalArgumentException | an argument's value is wrong (negative count, bad format) |
IllegalStateException | the object isn't in the right state for this call (not initialized, already closed) |
NullPointerException | an argument is null where it mustn't be |
IndexOutOfBoundsException | an index is out of range |
ConcurrentModificationException | a collection was modified while it was being iterated (when that's detected) |
UnsupportedOperationException | the object doesn't support this method (immutable collections) |
the specific beats the general: a null argument is an NPE, not an IllegalArgumentException. and never throw Exception, RuntimeException, Throwable or Error directly: callers can't tell your exception from every other one.
shaping exceptions
item 73 · translate exceptions at layer boundaries
a SQLException escaping from OrderService.place() leaks the fact that you use SQL, and changes your API the day you switch storage. catch the low-level exception at the boundary and throw one that speaks the higher layer's language, keeping the original as the cause:
hover a stage.
import java.io.*;
class RepositoryException extends RuntimeException {
RepositoryException(String msg, Throwable cause) { super(msg, cause); }
}
String loadUser(String id) {
try {
throw new FileNotFoundException("/data/users/" + id + ".json"); // the low-level failure
} catch (IOException e) {
throw new RepositoryException("can't load user " + id, e); // translated, cause kept
}
}
void main() {
try {
loadUser("42");
} catch (RepositoryException e) {
IO.println(e.getMessage());
IO.println("caused by: " + e.getCause());
}
}can't load user 42 caused by: java.io.FileNotFoundException: /data/users/42.json
don't overdo it: the best fix is often to make the low-level call not fail (validate first), or to log and handle it at the boundary so callers never see it.
item 74 · document every exception you throw
declare checked exceptions one by one in throws (never throws Exception), and document every exception, checked or not, with @throws and the condition that causes it. that list of unchecked exceptions is the method's preconditions. don't put unchecked ones in the throws clause, though: the javadoc tag is enough, and the clause is how readers tell checked from unchecked.
item 75 · put the failing values in the message
when a failure reaches a log, the message is often all anyone gets. include the values that caused it: not "index out of range" but "index 12 out of range for length 10". the cleanest way is a constructor that takes the values and builds the message, so every throw site gets it right. don't include passwords or keys.
record Order(String id, Customer customer) {}
record Customer(String name, Address address) {}
record Address(String city) {}
public class Main {
public static void main(String[] args) {
var order = new Order("o-7", new Customer("ada", null));
System.out.println("shipping " + order.id());
System.out.println(order.customer().address().city()); // which part was null?
}
}shipping o-7 Exception in thread "main" java.lang.NullPointerException: Cannot invoke "Address.city()" because the return value of "Customer.address()" is null
after the throw
item 76 · fail without half-changing state
a method that throws should leave the object as it was before the call (failure atomicity). otherwise the caller catches the exception and keeps going with an object in a state no one designed.
class Account {
long cents;
Account(long cents) { this.cents = cents; }
}
static void transfer(Account from, Account to, long amount) {
from.cents -= amount;
if (to == null) throw new IllegalArgumentException("no target account");
to.cents += amount;
}
void main() {
var mine = new Account(500);
try {
transfer(mine, null, 200);
} catch (IllegalArgumentException e) {
IO.println(e.getMessage() + ", balance now " + mine.cents);
}
}class Account {
long cents;
Account(long cents) { this.cents = cents; }
}
static void transfer(Account from, Account to, long amount) {
Objects.requireNonNull(to, "no target account"); // check everything first
if (amount > from.cents) throw new IllegalStateException("insufficient funds");
from.cents -= amount; // then mutate
to.cents += amount;
}
void main() {
var mine = new Account(500);
try {
transfer(mine, null, 200);
} catch (NullPointerException e) {
IO.println(e.getMessage() + ", balance still " + mine.cents);
}
}no target account, balance still 500
this one needs a real JVM (catching a runtime exception), so it doesn't run in the browser. copy it into java Main.java to try it.
four ways to get there: be immutable (failure atomicity for free), check everything before mutating, compute the result on a temporary copy and swap it in, or (rarely) write recovery code that rolls back. some failures can't be atomic (a ConcurrentModificationException means things already went wrong); document when you don't guarantee it.
item 77 · never swallow an exception
an empty catch block turns a crash you'd notice into a wrong result you won't. if ignoring really is right (closing a read-only stream, a best-effort cache refresh), say why in a comment and name the variable to show it's intentional.
try {
cache.refresh();
} catch (Exception e) {
}try {
cache.refresh();
} catch (IOException _) {
// best effort: stale entries are
// fine, next refresh will retry
}- exceptions are for exceptional cases. don't steer normal logic with them, they're slow and they hide bugs.
- checked = the caller can recover. unchecked = a bug. errors are the JVM's.
- a checked exception has to earn its cost. a sealed result type is often the better API now.
- throw the standard exceptions, translated to your layer, with the cause attached.
- put the failing values in the message, and check before you mutate.
- an empty catch is a bug waiting to be found in production. if you ignore, say why.