Skip to main content
  1. Posts/

Checked vs Unchecked Exceptions in Java — A Deep Dive

Java from Scratch - This article is part of a series.
Part 2: This Article

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 not RuntimeException). 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 operations
  • SQLException — database operations
  • ClassNotFoundException — reflection
  • InterruptedException — 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 classic
  • IllegalArgumentException — bad method input
  • IllegalStateException — object in wrong state
  • UnsupportedOperationException — method not implemented
  • NumberFormatException — bad number parsing
  • ClassCastException — 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 HiddenDefault

In 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:

  1. You compile HiddenDefault.java against version 1 of Animal (sealed, permits Dog and Cat)
  2. Someone later adds record Bird(String name) implements Animal {} and recompiles only Animal.java — not your switch
  3. 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 default needed in source
  • The JVM protects against binary incompatibility at runtime → hidden default throws IncompatibleClassChangeError
  • Because it’s an Error (not Exception), it doesn’t pollute your method signature with throws

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 EnumSwitch

Look 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 NullSwitch

You’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 IncompatibleClassChangeError

null 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 Exception or Throwable broadly — 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
#

CheckedUnchecked
ExtendsExceptionRuntimeException
Compiler enforcedYesNo
Must declare with throwsYesNo
Typical useExternal failuresProgramming errors
ExamplesIOException, SQLExceptionNullPointerException, IllegalArgumentException
In switch defaultInfects the whole expressionNo impact
In record constructorsAwkward — use factory methodsNatural fit for validation
Compiler’s hidden defaultN/A — uses IncompatibleClassChangeError (Error, not Exception)N/A
null in switchNPE 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.

Java from Scratch - This article is part of a series.
Part 2: This Article

Share this post

Related