Skip to content

5. Exceptions, null and Optional

Full examples: lessons/l05.

Checked exceptions: the compiler tracks what can fail

Section titled “Checked exceptions: the compiler tracks what can fail”

In C#, every exception is unchecked: a method’s signature doesn’t say what it throws, and the compiler never asks you to handle anything. Java splits the Throwable hierarchy in two:

Kind Classes Must be caught or declared? Typical use
checked Exception and its subclasses, except RuntimeException yes failures a correct program must still expect: I/O, network, parsing a file
unchecked RuntimeException and its subclasses no programming errors: NullPointerException, IllegalArgumentException, IndexOutOfBoundsException
unchecked Error and its subclasses no the JVM is in trouble: OutOfMemoryError, StackOverflowError

A method that can let a checked exception escape must say so with throws. Files.readString declares throws IOException, so this does not compile:

import java.nio.file.Files;
import java.nio.file.Path;
class Config {
static String load() {
return Files.readString(Path.of("app.properties"));
}
}
UnreportedException.java:6: error: unreported exception IOException; must be caught or declared to be thrown
return Files.readString(Path.of("app.properties"));
^
1 error

The two fixes are the ones the message names: catch it, or add throws IOException to load() and push the decision to its callers. The rules are in JLS §11.2.

static String readConfig(Path path) throws IOException {
return Files.readString(path);
}
static int parsePort(String text) {
// NumberFormatException is unchecked: nothing forces the caller to handle it.
return Integer.parseInt(text);
}

Java libraries use checked exceptions less than they used to. Newer APIs such as java.time throw only unchecked exceptions, and most frameworks wrap checked ones. The JDK’s I/O, reflection and concurrency APIs are where you will meet them every day.

Catch blocks: order, multi-catch and no filters

Section titled “Catch blocks: order, multi-catch and no filters”

The order rule is the same as C#’s CS0160: a catch can’t follow one that already handles a supertype.

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
class Reader {
static String read(Path path) {
try {
return Files.readString(path);
} catch (Exception e) {
return "";
} catch (IOException e) {
return "I/O error";
}
}
}
CatchOrder.java:11: error: exception IOException has already been caught
} catch (IOException e) {
^
1 error

Checked exceptions add a rule C# has no reason to have: catching a checked exception that the try body cannot throw is an error.

import java.io.IOException;
class Parser {
static int parse(String text) {
try {
return Integer.parseInt(text);
} catch (IOException e) {
return -1;
}
}
}
NeverThrown.java:7: error: exception IOException is never thrown in body of corresponding try statement
} catch (IOException e) {
^
1 error

A multi-catch handles several unrelated types in one block. The alternatives must not be subclasses of each other, since the subclass would be redundant:

for (String text : new String[] {"8080", "http"}) {
try {
System.out.println("port " + parsePort(text));
} catch (NumberFormatException | NullPointerException e) {
System.out.println(e.getClass().getSimpleName() + ": " + e.getMessage());
}
}
port 8080
NumberFormatException: For input string: "http"
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
class Loader {
static String load(Path path) {
try {
return Files.readString(path);
} catch (FileNotFoundException | IOException e) {
return "";
}
}
}
MulticatchSubclass.java:10: error: Alternatives in a multi-catch statement cannot be related by subclassing
} catch (FileNotFoundException | IOException e) {
^
Alternative FileNotFoundException is a subclass of alternative IOException
1 error

Java has no exception filters. The C# side of this lesson catches with when (e.Message.Contains("'http'")); the same line in Java is a syntax error:

class Filters {
static int parse(String text) {
try {
return Integer.parseInt(text);
} catch (NumberFormatException e) when (text.isBlank()) {
return 0;
}
}
}
ExceptionFilter.java:5: error: '{' expected
} catch (NumberFormatException e) when (text.isBlank()) {
^
ExceptionFilter.java:5: error: ';' expected
} catch (NumberFormatException e) when (text.isBlank()) {
^
ExceptionFilter.java:9: error: reached end of file while parsing
}
^
3 errors

Test inside the catch and rethrow what you don’t handle. Unlike a filter, this unwinds the stack before the test runs, which mostly matters to debuggers and crash dumps.

The functional interfaces of java.util.function declare no checked exceptions: Function.apply has no throws clause. A lambda that calls Files.readString therefore can’t be a Function:

import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
class Configs {
static List<String> loadAll(List<Path> paths) {
return paths.stream().map(path -> Files.readString(path)).toList();
}
}
CheckedInLambda.java:7: error: unreported exception IOException; must be caught or declared to be thrown
return paths.stream().map(path -> Files.readString(path)).toList();
^
1 error

This is the daily cost of checked exceptions. The usual answers are a plain loop, or catching inside the lambda and wrapping the exception in an unchecked one. Exercise 1 writes a reusable adapter for the second.

Wrap with the original exception as the cause, like C#’s innerException. For IOException the JDK provides UncheckedIOException:

static String loadOrWrap(Path path) {
try {
return readConfig(path);
} catch (IOException e) {
// Wrap in an unchecked exception and keep the original as the cause.
throw new UncheckedIOException("cannot load " + path.getFileName(), e);
}
}
cannot load missing.properties
caused by NoSuchFileException

A stack trace printed by the JVM shows the chain as Caused by: sections, the counterpart of .NET’s ---> inner exception lines.

A try with resources is Java’s using. The resource must implement AutoCloseable (Java’s IDisposable). Resources close in reverse order of declaration, before any catch or finally block of the same statement runs:

record Connection(String name, boolean failOnClose) implements AutoCloseable {
Connection {
System.out.println("open " + name);
}
@Override
public void close() {
System.out.println("close " + name);
if (failOnClose) {
throw new IllegalStateException("close failed: " + name);
}
}
}
try (var db = new Connection("db", false);
var cache = new Connection("cache", false)) {
System.out.println("using " + db.name() + " and " + cache.name());
} finally {
System.out.println("finally");
}
open db
open cache
using db and cache
close cache
close db
finally

The two languages differ when the body throws and then closing throws too. Java keeps the body’s exception, the one that explains what went wrong, and attaches the other one to it as a suppressed exception:

// The body fails, then close fails too: the body's exception wins,
// and the close exception is attached to it instead of replacing it.
try (var db = new Connection("db", true)) {
throw new IllegalArgumentException("query failed on " + db.name());
} catch (IllegalArgumentException e) {
System.out.println("caught: " + e.getMessage());
for (Throwable suppressed : e.getSuppressed()) {
System.out.println("suppressed: " + suppressed.getMessage());
}
}
open db
close db
caught: query failed on db
suppressed: close failed: db

In C#, a using is a try/finally. An exception thrown by Dispose replaces the one in flight, and query failed on db is lost:

dispose db
caught: dispose failed: db

The resource type is checked at compile time. There is no duck typing, and a class with a close() method that doesn’t implement AutoCloseable is rejected:

class Session {
void end() {}
static void run() {
try (var session = new Session()) {
System.out.println("working");
}
}
}
NotAutoCloseable.java:5: error: incompatible types: try-with-resources not applicable to variable type
try (var session = new Session()) {
^
(Session cannot be converted to AutoCloseable)
1 error

close() can itself throw a checked exception. BufferedReader.close() declares IOException, so the implicit call needs handling even when the body can’t fail:

import java.io.BufferedReader;
import java.io.StringReader;
class FirstLine {
static String firstLine(String text) {
try (BufferedReader reader = new BufferedReader(new StringReader(text))) {
return reader.readLine();
}
}
}
ImplicitClose.java:6: error: unreported exception IOException; must be caught or declared to be thrown
try (BufferedReader reader = new BufferedReader(new StringReader(text))) {
^
exception thrown from implicit call to close() on resource variable 'reader'
ImplicitClose.java:7: error: unreported exception IOException; must be caught or declared to be thrown
return reader.readLine();
^
2 errors

The full semantics, including the order of closing and suppression, are in JLS §14.20.3.

A Java exception captures its stack trace when it is created, not when it is thrown. throw e in a catch block therefore keeps the original trace, and Java has no bare throw;:

static void failDeep() {
throw new IllegalStateException("deep failure");
}
static void rethrow() {
try {
failDeep();
} catch (IllegalStateException e) {
// In Java the stack trace is captured when the exception is created,
// so "throw e" keeps it. C# needs "throw;" for the same result.
throw e;
}
}
thrown in failDeep

The C# side shows why the C# rule exists. throw e; resets the trace to the rethrowing method, and the analyzer reports CA2200:

throw e: thrown in RethrowWithVariable
throw; thrown in FailDeep

C# forbids return inside finally (CS0157). Java allows it, and the finally result silently replaces the try result, or even an exception in flight. -Xlint:finally, included in -Xlint:all, warns about it:

class Totals {
static int total() {
try {
return 1;
} finally {
return 2;
}
}
}
FinallyReturn.java:7: warning: [finally] finally clause cannot complete normally
}
^
1 warning

total() returns 2. Keep finally for cleanup, and prefer try-with-resources when the cleanup is closing something.

C# 8’s nullable reference types make string and string? different types for the compiler. Java has nothing like it. Every reference can be null, javac gives no warning, and you find out at run time.

What Java does have is a precise message. Since JEP 358 (Java 14, on by default since 15), a NullPointerException names what was null:

static Customer lookup(String id) {
return CUSTOMERS.get(id); // null when the id is unknown
}
System.out.println(lookup("grace").name().length());
Cannot invoke "lessons.l05.Nulls$Customer.name()" because the return value of "lessons.l05.Nulls.lookup(String)" is null

The same chain in C# (with ! to silence the nullable warning) gives only Object reference not set to an instance of an object.

To fail early, Objects.requireNonNull is the counterpart of ArgumentNullException.ThrowIfNull:

new Customer(Objects.requireNonNull(null, "name"), null);
requireNonNull: name

Static analysis can bring back part of the compile-time checking. JSpecify standardises @Nullable and @NullMarked annotations, and tools such as NullAway enforce them at build time. Lesson 11 sets them up and shows NullAway rejecting this kind of code.

Optional<T> is a container that holds a value or nothing. It is a library class, not language syntax, and its methods cover C#’s null operators:

C# Java Optional
x?.Property .map(X::property)
x?.Method() returning T? .flatMap(X::method) returning Optional
x ?? fallback .orElse(fallback)
x ?? Compute() .orElseGet(() -> compute())
x ?? throw new … .orElseThrow(() -> new …)
x! .orElseThrow() — throws NoSuchElementException
if (x is { } value) .ifPresent(value -> …)
static Optional<Customer> find(String id) {
return Optional.ofNullable(CUSTOMERS.get(id));
}
// C#: customer?.Address?.City ?? "unknown"
for (String id : new String[] {"ada", "alan", "grace"}) {
String city = find(id).map(Customer::address).map(Address::city).orElse("unknown");
System.out.println(id + " -> " + city);
}
ada -> London
alan -> unknown
grace -> unknown

map turns a null result into an empty Optional, so alan (no address) and grace (no customer) both fall through to orElse. The C# side prints the same three lines with customers.GetValueOrDefault(id)?.Address?.City ?? "unknown".

One difference from ??: orElse is an ordinary method, so its argument is evaluated before the call, even when the value is present. Use orElseGet when the fallback is expensive or has side effects:

System.out.println("orElse:");
find("ada").map(Customer::name).orElse(defaultCity());
System.out.println("orElseGet:");
find("ada").map(Customer::name).orElseGet(Nulls::defaultCity);
orElse:
computing default city
orElseGet:

Optional is meant for return values. Its Javadoc says it “is primarily intended for use as a method return type where there is a clear need to represent ‘no result,’ and where using null is likely to cause errors.” Don’t use it for fields, parameters or collection elements, and never return a null Optional. For primitives, OptionalInt, OptionalLong and OptionalDouble avoid boxing (lesson 4).

  • Checked exceptions (Exception but not RuntimeException) must be caught or declared with throws; C# has none.
  • Multi-catch replaces repeated catch blocks; there are no when filters.
  • Lambdas for java.util.function interfaces can’t throw checked exceptions: wrap them, for example in UncheckedIOException.
  • try-with-resources closes in reverse order and keeps the body’s exception, adding close failures as suppressed exceptions.
  • throw e keeps the stack trace; a return in finally overrides everything.
  • No nullable reference types: rely on helpful NPE messages, requireNonNull, and Optional for return values.
  1. Write an adapter unchecked so that paths.stream().map(unchecked(Files::readString)).toList() compiles. An IOException should surface as an UncheckedIOException with the original as its cause.
Solution
@FunctionalInterface
interface ThrowingFunction<T, R> {
R apply(T value) throws Exception;
}
static <T, R> Function<T, R> unchecked(ThrowingFunction<T, R> function) {
return value -> {
try {
return function.apply(value);
} catch (IOException e) {
throw new UncheckedIOException(e);
} catch (RuntimeException e) {
throw e;
} catch (Exception e) {
throw new RuntimeException(e);
}
};
}

ThrowingFunction is a functional interface whose method does declare throws Exception, so Files::readString fits it. The RuntimeException clause rethrows unchecked exceptions unchanged instead of wrapping them twice. Libraries such as Vavr ship the same idea, but the dozen lines are often simpler than a dependency.

  1. Translate this C# method. Which return type avoids boxing?
static int? ParsePort(string text) =>
int.TryParse(text, out var port) && port is >= 0 and <= 65535 ? port : null;
var port = ParsePort(args[0]) ?? 8080;
Solution
static OptionalInt parsePort(String text) {
try {
int port = Integer.parseInt(text);
return port >= 0 && port <= 65535 ? OptionalInt.of(port) : OptionalInt.empty();
} catch (NumberFormatException e) {
return OptionalInt.empty();
}
}
int port = parsePort(args[0]).orElse(8080);

OptionalInt holds an int directly; Optional<Integer> would box it. Java has no TryParse, so the exception is the failure signal. parsePort("http") and parsePort("70000") both give 8080.

  1. Predict the output, then check it. Which exception reaches the caller, and in what order are the others recorded?
record Resource(String name, List<String> log) implements AutoCloseable {
@Override
public void close() {
log.add("close " + name);
throw new IllegalStateException("close " + name);
}
}
try (var first = new Resource("first", log);
var second = new Resource("second", log)) {
throw new IllegalArgumentException("body " + first.name() + " " + second.name());
}
Solution

The caller gets the IllegalArgumentException with the message body first second. second closes before first, so log is [close second, close first], and getSuppressed() holds the two IllegalStateExceptions in the same order: close second, then close first. Every resource is closed even though each close() throws. In C#, nested using declarations would also dispose both, but the caller would see only close first, the last exception thrown.