Checked vs Unchecked Exceptions

Know which exceptions the compiler forces you to handle, why, and how back-end code deals with checked exceptions in lambdas and layers.

SyntaxThrowable ├── Error (unchecked) └── Exception (checked) └── RuntimeException (unchecked)

Every exception is a Throwable. Below it are two branches: Error, for problems in the JVM itself such as OutOfMemoryError and StackOverflowError, and Exception. Under Exception sits RuntimeException.

KindTypesCompiler rule
CheckedException and subclasses that are not RuntimeException: IOException, SQLException, TimeoutExceptionMust be caught or declared with throws
UncheckedRuntimeException and subclasses: IllegalArgumentException, NullPointerException, IllegalStateExceptionNo requirement
UncheckedError and subclassesNo requirement; do not catch

The intent

Checked exceptions model failures a correct program can still meet: a file is missing, the network drops, the database rejects a query. The compiler makes the caller decide what to do. Unchecked exceptions usually signal a bug or a broken precondition: a null where there should not be one, an argument out of range. Catching those in every method only hides the bug.

Where checked exceptions hurt

The functional interfaces in java.util.function declare no checked exceptions, so a lambda passed to map cannot throw IOException. The standard answer is to catch it inside the lambda and wrap it in UncheckedIOException (or your own unchecked type), then unwrap at the boundary if you need to.

Layering is the other pain point. A SQLException declared on a repository leaks JDBC into every service signature above it. Most back-end code translates low-level checked exceptions into unchecked domain exceptions at the layer boundary, keeping the original as the cause. Spring does exactly this, turning SQLException into its unchecked DataAccessException hierarchy.

Mistakes to avoid

  • catch (Exception e) written to satisfy the compiler for one IOException also catches every RuntimeException in the block, including your bugs. Catch the checked type you mean.
  • Declaring throws Exception on everything throws away the information the compiler was giving you.
  • Catching Throwable or Error in normal code. After an OutOfMemoryError the process is rarely in a state worth continuing.

One production detail: Spring's @Transactional rolls back by default only for unchecked exceptions. A checked exception thrown from a transactional method commits the work unless you configure rollbackFor. That default is another reason many teams make their domain exceptions unchecked.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Wrap an IOException thrown while reading files inside a stream pipeline in UncheckedIOException.
  • Translate a checked SQLException into an unchecked domain exception at the repository boundary.
  • Decide whether a new domain exception should roll back a Spring transaction by default.

More examples

The compiler makes you handle a checked exception

Remove the try and the program no longer compiles, because readConfig declares IOException. NoSuchFileException is a subclass, so it gets its own clause first and other I/O failures fall through to the general one.

Example · java
String readConfig(Path file) throws IOException {
    return Files.readString(file);
}

void main() {
    Path file = Path.of("missing-config.properties");
    try {
        IO.println(readConfig(file));
    } catch (NoSuchFileException e) {
        IO.println("config missing: " + e.getMessage());
    } catch (IOException e) {
        IO.println("config unreadable: " + e.getMessage());
    }
}

// Output:
// config missing: missing-config.properties

Checked exceptions inside a stream lambda

UncheckedIOException exists for exactly this case and always holds the original IOException as its cause. Unwrap it with getCause() where you handle the failure.

Example · java
String load(String name) throws IOException {
    if (name.startsWith("bad")) {
        throw new IOException("cannot read " + name);
    }
    return name.toUpperCase();
}

void main() {
    List<String> names = List.of("a.txt", "bad.txt", "c.txt");
    try {
        List<String> loaded = names.stream()
            .map(n -> {
                try {
                    return load(n);
                } catch (IOException e) {
                    // Function.apply cannot throw IOException, so wrap it.
                    throw new UncheckedIOException(e);
                }
            })
            .toList();
        IO.println(loaded);
    } catch (UncheckedIOException e) {
        IO.println("failed: " + e.getCause().getMessage());
    }
}

// Output:
// failed: cannot read bad.txt

catch (Exception e) hides a bug

The first version reports a missing user as an outage, which sends someone to debug the wrong system. Catching only IOException lets the real bug surface, and the fixed version then handles the null case on purpose.

Example · java
record User(String name) {}

User find(String id) throws IOException {
    if (id.equals("timeout")) {
        throw new IOException("upstream timed out");
    }
    return id.equals("42") ? new User("Ada") : null;
}

String greet(String id) {
    try {
        return "Hello " + find(id).name();
    } catch (Exception e) {
        // Meant for IOException, but also swallows the NullPointerException.
        return "upstream unavailable";
    }
}

String greetFixed(String id) {
    try {
        User user = find(id);
        return user == null ? "no such user" : "Hello " + user.name();
    } catch (IOException e) {
        return "upstream unavailable";
    }
}

void main() {
    IO.println(greet("42"));
    IO.println(greet("7"));
    IO.println(greetFixed("7"));
    IO.println(greetFixed("timeout"));
}

// Output:
// Hello Ada
// upstream unavailable
// no such user
// upstream unavailable

Discussion

  • Be the first to comment on this lesson.