Java Exceptions: Checked, Unchecked, and Best Practices
Throw, catch, and chain exceptions without leaking abstractions. Checked vs unchecked, try with resources, custom exceptions, and rules that scale.
What you'll learn
- ✓Distinguish checked, unchecked, and errors
- ✓Use try, catch, finally, and try with resources correctly
- ✓Throw and chain exceptions with cause
- ✓Design custom exceptions that carry context
- ✓Apply rules of thumb that scale across a codebase
Prerequisites
Exceptions are how Java says “this code path cannot continue normally”. Used well, they keep happy path code clean and push error handling to the right boundary. Used badly, they become a second control flow that hides bugs and turns stack traces into novels. The mechanics are simple; the discipline is what takes practice.
The hierarchy
Every exception is a subclass of java.lang.Throwable. Two branches matter:
Errorand its subclasses (OutOfMemoryError,StackOverflowError) signal JVM level problems. Do not catch them.Exceptionis for application problems. Its subclassRuntimeExceptionis unchecked; everything else underExceptionis checked.
Throwable
Error (do not catch)
Exception (checked)
IOException
SQLException
RuntimeException (unchecked)
NullPointerException
IllegalArgumentException
IllegalStateException
Checked vs unchecked
Checked exceptions must be declared in the method signature or caught. The compiler enforces it.
import java.io.*;
public String readFirstLine(String path) throws IOException {
try (var br = new BufferedReader(new FileReader(path))) {
return br.readLine();
}
}
Unchecked exceptions do not need declaration. They signal programming bugs (null deref, bad argument, illegal state) that callers usually cannot handle.
The rule of thumb: throw checked for recoverable conditions a caller can reasonably react to (file missing, network down); throw unchecked for “this should never happen if the caller used the API correctly”. Modern Java style leans unchecked. Many libraries wrap checked exceptions in runtime ones to keep call sites clean.
try, catch, finally
try {
risky();
} catch (IOException e) {
log.error("io failed", e);
} catch (RuntimeException e) {
log.error("unexpected", e);
} finally {
cleanup(); // always runs
}
A single catch can list multiple types separated by |:
try {
parse();
} catch (IOException | NumberFormatException e) {
log.error("input failure", e);
}
Inside such a multi catch, e is effectively a common supertype, so you can only call methods that exist on both.
try with resources
If a resource implements AutoCloseable, use try with resources. The runtime closes it for you, even on exception, in reverse declaration order.
try (var input = new FileInputStream("in.bin");
var output = new FileOutputStream("out.bin")) {
input.transferTo(output);
}
This replaces the classic try { ... } finally { close(); } pattern and handles the edge case where close itself throws (it gets attached as a suppressed exception).
Throwing
Use throw to raise an exception. Use the most specific built in type that fits before reaching for a custom class.
public void setAge(int age) {
if (age < 0) {
throw new IllegalArgumentException("age must be non-negative, got " + age);
}
this.age = age;
}
Always include enough context in the message to debug from a log alone. Variable values matter; class name does not (the stack trace already has it).
Chaining
When you catch one exception and throw another, pass the original as the cause. You keep the full chain in the stack trace.
try {
parseConfig();
} catch (IOException e) {
throw new ConfigException("could not load config from " + path, e);
}
Never swallow an exception silently:
catch (Exception e) {
// silent: do not do this
}
If you truly do not care, log at debug level with the throwable and a one line reason.
Custom exceptions
Define your own when callers benefit from a distinct type they can catch separately.
public class PaymentDeclinedException extends RuntimeException {
private final String code;
public PaymentDeclinedException(String code, String message) {
super(message);
this.code = code;
}
public PaymentDeclinedException(String code, String message, Throwable cause) {
super(message, cause);
this.code = code;
}
public String code() { return code; }
}
Keep the hierarchy shallow. Two or three exception types in a module is plenty. A type per error condition becomes noise.
try as expression: pattern friendly
Java’s try is a statement, not an expression. To compute a value from a try, assign inside the block or extract a helper:
String result;
try {
result = compute();
} catch (Exception e) {
result = "fallback";
}
Or a tiny helper that hides the noise:
static <T> T runOr(Callable<T> c, T fallback) {
try { return c.call(); } catch (Exception e) { return fallback; }
}
String result = runOr(this::compute, "fallback");
NullPointerException, helpfully
Modern JVMs print helpful NPE messages that point to the exact expression that was null:
Cannot invoke "String.length()" because "name" is null
To prevent NPEs at API boundaries, validate with Objects.requireNonNull:
this.name = Objects.requireNonNull(name, "name");
It fails fast with a clear message, before the null travels deeper into your code.
Rules of thumb
- Catch where you can act. Bubbling to the top is fine if nothing closer can help.
- Do not catch
Exceptionbroadly unless you are at a top level boundary (HTTP handler, scheduled job, main method). - Do not use exceptions for normal control flow (returning a sentinel or
Optionalis almost always better). - Always preserve the cause when wrapping.
- Never let a
finallyblock hide a thrown exception by returning a value.
Logging vs rethrowing
A common antipattern is to both log and rethrow at every level, which produces ten copies of the same trace. Pick one: handle and log, or propagate. The boundary code (controller, job runner) is responsible for the final log.
Wrap up
Exceptions express failure cleanly only if you keep the model small: throw with context, catch where you can act, never lose the cause. Combine the discipline here with the design patterns from Java Classes and Objects and the safe iteration habits from Collections, and your error paths will stop being the scary part of the codebase.