Every Java developer hits exceptions early. But the difference between checked and unchecked exceptions — and when to use which — trips people up for years. This post goes deep.
The Exception Hierarchy#
Everything starts from Throwable:
Throwable
├── Error (unchecked — don't catch these)
│ ├── OutOfMemoryError
│ ├── StackOverflowError
│ └── ...
└── Exception (checked — must handle or declare)
├── IOException
├── SQLException
├── ...
└── RuntimeException (unchecked — optional to handle)
├── NullPointerException
├── IllegalArgumentException
├── ArrayIndexOutOfBoundsException
└── ...The rule is simple:
- Checked exceptions extend
Exception(but notRuntimeException). The compiler forces you to handle them. - Unchecked exceptions extend
RuntimeException. The compiler doesn’t care. - Errors extend
Error. These are JVM-level problems. Don’t catch them.
Checked Exceptions: The Compiler Enforces#
A checked exception means: “This thing can go wrong, and you must acknowledge it.”
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
void main() {
try {
readFile();
} catch (IOException ioException) {
IO.println("Failed to read file: %s".formatted(ioException.getMessage()));
}
}
void readFile() throws IOException {
Files.readString(Path.of("data.txt"));
}Common checked exceptions:
IOException— file/network operationsSQLException— database operationsClassNotFoundException— reflectionInterruptedException— thread operations
Unchecked Exceptions: Your Problem, Your Choice#
Unchecked exceptions are programming errors. The compiler won’t force you to catch them because they’re usually bugs you should fix, not conditions you should handle.
void main() {
// These throw unchecked exceptions at runtime
String nullableText = null;
// nullableText.length(); // NullPointerException
int[] numbers = {1, 2, 3};
// numbers[5] = 10; // ArrayIndexOutOfBoundsException
int parsedNumber = Integer.parseInt("abc"); // NumberFormatException
}Common unchecked exceptions:
NullPointerException— the classicIllegalArgumentException— bad method inputIllegalStateException— object in wrong stateUnsupportedOperationException— method not implementedNumberFormatException— bad number parsingClassCastException— invalid cast
Creating Your Own Exceptions#
When you write your own, the checked vs unchecked decision matters:
// Checked — callers MUST handle this
public class InsufficientFundsException extends Exception {
private final double amount;
private final double balance;
public InsufficientFundsException(double amount, double balance) {
super("Cannot withdraw %.2f - balance is only %.2f".formatted(amount, balance));
this.amount = amount;
this.balance = balance;
}
public double getAmount() { return amount; }
public double getBalance() { return balance; }
}
// Unchecked — callers can choose to handle this
public class InvalidTransactionException extends RuntimeException {
public InvalidTransactionException(String message) {
super(message);
}
}When to use which:
- Use checked when the caller can reasonably recover (retry, fallback, prompt user)
- Use unchecked when it’s a programming error (bad input validation, illegal state)
Switch Expressions and Checked Exceptions#
Here’s something that catches people off guard. A switch expression requires exhaustive cases — and the default branch can surface checked exceptions in surprising ways.
The problem#
import java.io.IOException;
void main() {
try {
IO.println(parse(FileType.JSON, "{\"key\": \"value\"}"));
} catch (IOException ioException) {
IO.println("Parse failed: %s".formatted(ioException.getMessage()));
}
}
enum FileType { CSV, JSON, XML }
String parse(FileType type, String data) throws IOException {
return switch (type) {
case CSV -> parseCsv(data);
case JSON -> parseJson(data);
case XML -> parseXml(data);
};
}
String parseCsv(String data) throws IOException {
if (data.isEmpty()) throw new IOException("Empty CSV data");
return "parsed-csv: %s".formatted(data);
}
String parseJson(String data) throws IOException {
if (data.isEmpty()) throw new IOException("Empty JSON data");
return "parsed-json: %s".formatted(data);
}
String parseXml(String data) throws IOException {
if (data.isEmpty()) throw new IOException("Empty XML data");
return "parsed-xml: %s".formatted(data);
}This works because the enum is exhaustive. But what if you use a String or int in a switch expression?
static String describe(int statusCode) {
return switch (statusCode) {
case 200 -> "OK";
case 404 -> "Not Found";
case 500 -> "Internal Server Error";
default -> throw new IllegalArgumentException(
"Unknown status code: " + statusCode
);
// default throws an unchecked exception — no problem
};
}If the default branch called a method that throws a checked exception, the whole switch expression would need to declare or handle it — even though you might think default “never” happens.
// This forces the caller to handle IOException
// even for status codes like 200 and 404
import java.nio.file.Files;
import java.nio.file.Path;
static String processStatus(int code) throws IOException {
return switch (code) {
case 200 -> "OK";
case 404 -> "Not Found";
default -> loadFromFile(code); // throws IOException
};
}
static String loadFromFile(int code) throws IOException {
return new String(Files.readAllBytes(
Path.of("status_" + code + ".txt")
));
}This is a design decision to be aware of. If your default branch introduces a checked exception, it infects the entire switch expression.
Records and Exceptions#
Records are great for data. But their compact constructors are a natural place for validation — and exception choices matter here.
void main() {
var person = new Person(
"Tebogo",
new Email("TEBOGO@EXAMPLE.COM"),
new Age(28)
);
IO.println(person);
try {
new Email("not-an-email");
} catch (IllegalArgumentException e) {
IO.println("Caught: %s".formatted(e.getMessage()));
}
try {
new Age(-5);
} catch (IllegalArgumentException e) {
IO.println("Caught: %s".formatted(e.getMessage()));
}
}
record Email(String address) {
Email {
if (address == null || address.isBlank()) {
throw new IllegalArgumentException("Email cannot be blank");
}
if (!address.contains("@")) {
throw new IllegalArgumentException("Invalid email: %s".formatted(address));
}
address = address.toLowerCase().strip();
}
}
record Age(int value) {
Age {
if (value < 0 || value > 150) {
throw new IllegalArgumentException("Age must be between 0 and 150, got: %d".formatted(value));
}
}
}
record Person(String name, Email email, Age age) {}Why unchecked here? Because passing invalid data to a constructor is a programming error. The caller should validate before constructing. You wouldn’t use a checked exception for new ArrayList<>(-1) — same logic.
Exception: If your record wraps data from external input (user form, API request, file), you might want a checked exception to force callers to handle the validation failure. In that case, use a static factory method instead:
record Email(String address) {
Email {
// Still validate, but this is the "trusted" path
assert address != null && address.contains("@");
address = address.toLowerCase().strip();
}
// Factory method with checked exception for untrusted input
static Email parse(String raw) throws InvalidEmailException {
if (raw == null || !raw.contains("@")) {
throw new InvalidEmailException(raw);
}
return new Email(raw);
}
}Sealed Interfaces + Pattern Matching + Exceptions#
Sealed interfaces define a closed set of implementations. Combined with pattern matching in switch, you get exhaustive type checking — and no default branch needed.
import java.io.IOException;
void main() {
Result<String> successfulResult = new Success<>("data loaded");
Result<String> failedResult = new Failure<>(new IOException("timeout"));
Result<String> pendingResult = new Pending<>("task-42");
IO.println(describe(successfulResult));
IO.println(describe(failedResult));
IO.println(describe(pendingResult));
}
sealed interface Result<T> permits Success, Failure, Pending {}
record Success<T>(T value) implements Result<T> {}
record Failure<T>(Exception error) implements Result<T> {}
record Pending<T>(String taskId) implements Result<T> {}
<T> String describe(Result<T> result) {
return switch (result) {
case Success<T>(var value) -> "Success: %s".formatted(value);
case Failure<T>(var error) -> "Failed: %s".formatted(error.getMessage());
case Pending<T>(var taskId) -> "Pending: task %s".formatted(taskId);
};
}This pattern is powerful. You’ve essentially built a Result type (similar to Rust’s Result) using sealed interfaces and records. The compiler guarantees you handle every case.
Using this for error handling instead of exceptions#
void main() {
var divisionResult = divide(10, 3);
switch (divisionResult) {
case Ok<Integer>(var value) -> IO.println("Result: %d".formatted(value));
case Err<Integer>(var message, _) -> IO.println("Error: %s".formatted(message));
}
var parsedResult = parseInt("abc");
switch (parsedResult) {
case Ok<Integer>(var value) -> IO.println("Parsed: %d".formatted(value));
case Err<Integer>(var message, _) -> IO.println("Parse error: %s".formatted(message));
}
}
sealed interface Result<T> permits Ok, Err {}
record Ok<T>(T value) implements Result<T> {}
record Err<T>(String message, Exception cause) implements Result<T> {
Err(String message) { this(message, null); }
}
Result<Integer> divide(int dividend, int divisor) {
if (divisor == 0) {
return new Err<>("Cannot divide by zero");
}
return new Ok<>(dividend / divisor);
}
Result<Integer> parseInt(String text) {
try {
return new Ok<>(Integer.parseInt(text));
} catch (NumberFormatException e) {
return new Err<>("Not a number: %s".formatted(text), e);
}
}This gives you explicit error handling without the overhead of exception stack traces. Good for expected failures. Keep exceptions for truly exceptional situations.
What the Compiler Actually Does with Switch — The Hidden Default#
Here’s the part most tutorials skip. When you write an exhaustive switch over a sealed type and leave out default, the compiler still inserts one behind the scenes. And it throws a checked-style error.
The source code#
sealed interface Animal permits Dog, Cat {}
record Dog(String name) implements Animal {}
record Cat(String name) implements Animal {}
String sound(Animal animal) {
return switch (animal) {
case Dog(var name) -> "%s says woof".formatted(name);
case Cat(var name) -> "%s says meow".formatted(name);
};
}
void main() {
IO.println(sound(new Dog("Rex")));
IO.println(sound(new Cat("Whiskers")));
}This compiles and runs fine. The compiler is satisfied — Animal is sealed, and all permitted subtypes are covered. No default needed.
But what does the compiled bytecode actually look like?
Proving it with javap#
Compile and disassemble:
javac HiddenDefault.java
javap -c HiddenDefaultIn the bytecode output for the sound method, you’ll find something like this at the end of the switch:
// ... the case matching logic ...
default -> throw new IncompatibleClassChangeError(...)The compiler inserts a hidden default branch that throws IncompatibleClassChangeError. This is an Error (extends LinkageError extends Error), not a regular exception — so it doesn’t require a throws declaration.
Why does the compiler do this?#
Because Java compiles separately. Consider this scenario:
- You compile
HiddenDefault.javaagainst version 1 ofAnimal(sealed, permitsDogandCat) - Someone later adds
record Bird(String name) implements Animal {}and recompiles onlyAnimal.java— not your switch - At runtime, your switch receives a
Bird— a type that didn’t exist when you compiled
The JVM can’t just crash silently. The hidden default catches this and throws IncompatibleClassChangeError — telling you the class structure changed incompatibly since compile time.
What this means for exception handling#
This is important for understanding how Java protects you:
- The compiler proves exhaustiveness at compile time → no
defaultneeded in source - The JVM protects against binary incompatibility at runtime → hidden
defaultthrowsIncompatibleClassChangeError - Because it’s an
Error(notException), it doesn’t pollute your method signature withthrows
This is fundamentally different from a regular default that you write yourself. Your default would typically throw an unchecked IllegalArgumentException or IllegalStateException. The compiler’s hidden default throws a linkage error because the problem isn’t bad input — it’s that the class hierarchy changed out from under you.
Contrast: enum switch#
The same thing happens with enums. If you cover all enum constants:
enum Direction { NORTH, SOUTH, EAST, WEST }
static String arrow(Direction direction) {
return switch (direction) {
case NORTH -> "↑";
case SOUTH -> "↓";
case EAST -> "→";
case WEST -> "←";
};
}The compiled bytecode still has a hidden default. If someone adds NORTHEAST to the enum and recompiles only the enum, your switch would hit that hidden default at runtime and throw IncompatibleClassChangeError.
You can verify this yourself:
javac Direction.java EnumSwitch.java
javap -c EnumSwitchLook for the default branch in the tableswitch or lookupswitch instruction — it’s always there.
null: The Special Case in Switch#
null gets its own rules in switch expressions, and it interacts with exceptions in ways that might surprise you.
Before Java 21: null always throws#
In traditional switch statements, passing null throws NullPointerException before any case is evaluated:
static String old_style(String text) {
// If text is null, this throws NullPointerException
// BEFORE any case is checked
return switch (text) {
case "hello" -> "greeting";
case "bye" -> "farewell";
default -> "unknown";
};
}
void main() {
old_style(null); // NullPointerException — default doesn't catch it
}That’s right — default does NOT catch null. The NPE happens before the switch logic even starts.
Java 21+: explicit null case#
Pattern matching switch lets you handle null explicitly:
static String withNullCase(String text) {
return switch (text) {
case "hello" -> "greeting";
case "bye" -> "farewell";
case null -> "nothing"; // Explicit null handling
default -> "unknown";
};
}Now null is caught by the case null branch instead of throwing NPE.
null with sealed interfaces#
This is where it gets interesting. Even with a sealed interface where you cover all subtypes, null is still not covered:
sealed interface Shape permits Circle, Square {}
record Circle(double radius) implements Shape {}
record Square(double sideLength) implements Shape {}
static String describe(Shape shape) {
return switch (shape) {
case Circle circle -> "circle radius=" + circle.radius();
case Square square -> "square side=" + square.sideLength();
// Exhaustive for non-null values — compiles fine
};
}This compiles. But at runtime:
describe(null); // NullPointerException!The sealed interface exhaustiveness check covers all types but not null. If you want null-safety, you need to add it:
static String describeSafe(Shape shape) {
return switch (shape) {
case Circle c -> "circle r=" + c.r();
case Square s -> "square s=" + s.s();
case null -> "no shape";
};
}Compiled proof: how null is handled#
Compile and disassemble a switch with a case null:
javac NullSwitch.java
javap -c NullSwitchYou’ll see that the compiler generates a null check before the type-checking logic. The bytecode roughly does:
1. if (input == null) → jump to null case label
2. if (input instanceof Circle) → jump to circle case
3. if (input instanceof Square) → jump to square case
4. default → throw IncompatibleClassChangeErrornull is checked first, as a special case, separate from the type pattern matching. This is why default doesn’t catch null — null checking and type matching are different operations at the bytecode level.
null + guards#
You can combine null with guard conditions too:
static String process(Object obj) {
return switch (obj) {
case null -> "null input";
case String text when text.isEmpty() -> "empty string";
case String text -> "string: " + text;
case Integer number when number < 0 -> "negative: " + number;
case Integer number -> "number: " + number;
default -> "other: " + obj.getClass().getSimpleName();
};
}The case null must come before other cases or be combined with default:
// Also valid — null and default combined
return switch (obj) {
case String text -> "string";
case Integer number -> "number";
case null, default -> "null or something else";
};This is a clean pattern when you want to treat null the same as an unknown type.
Practical Guidelines#
Use checked exceptions when:
- The caller can realistically recover (retry, fallback, prompt user)
- It’s an external system failure (file not found, network down, DB unavailable)
- You want the compiler to enforce handling
Use unchecked exceptions when:
- It’s a programming error (null where it shouldn’t be, invalid argument)
- The caller can’t do anything useful about it
- You’re writing library code and don’t want to pollute every method signature
General rules:
- Don’t catch
ExceptionorThrowablebroadly — catch specific types - Don’t use exceptions for control flow (expensive and confusing)
- Don’t swallow exceptions silently — at minimum, log them
- Prefer unchecked for new code unless you have a strong reason for checked
- Use sealed interfaces + Result pattern for expected failures in business logic
Summary#
| Checked | Unchecked | |
|---|---|---|
| Extends | Exception | RuntimeException |
| Compiler enforced | Yes | No |
Must declare with throws | Yes | No |
| Typical use | External failures | Programming errors |
| Examples | IOException, SQLException | NullPointerException, IllegalArgumentException |
| In switch default | Infects the whole expression | No impact |
| In record constructors | Awkward — use factory methods | Natural fit for validation |
| Compiler’s hidden default | N/A — uses IncompatibleClassChangeError (Error, not Exception) | N/A |
| null in switch | NPE thrown before switch logic (pre-Java 21) | case null handles it explicitly (Java 21+) |
Next in this series, we’ll look at building something real with these concepts.


