Exceptions, Optional and Failing on Purpose

Article · 13:00

Java splits failure into three, and the split is the lesson: checked for what a caller can do something about, unchecked for a programmer error, Error for what nobody can. With try-with-resources, keeping the cause, and Optional as a return type.

Exceptions, Optional and Failing on Purpose

Java splits failure into three, and the split is the lesson.

  • Checked exceptions extend Exception. The compiler forces the caller to catch them or declare them. They are for conditions a caller can reasonably recover from: a missing file, a refused connection.
  • Unchecked exceptions extend RuntimeException. Nothing forces anyone to handle them. They are for programming mistakes: a null reference, an index off the end, an argument that breaks a documented rule.
  • Errors extend Error: OutOfMemoryError, StackOverflowError. The program is in trouble. Do not catch these.
public class Kinds {
    public static void main(String[] args) {
        // Unchecked - the code was wrong. Catching it hides a bug.
        try {
            int[] values = {1, 2, 3};
            System.out.println(values[7]);
        } catch (ArrayIndexOutOfBoundsException e) {
            System.out.println("unchecked: " + e.getMessage());
        }

        // Also unchecked, and the most common of all.
        try {
            String value = null;
            System.out.println(value.length());
        } catch (NullPointerException e) {
            // Java 14+ says exactly which reference was null.
            System.out.println("unchecked: " + e.getMessage());
        }

        // Checked - the input was wrong, and the caller can react.
        try {
            Integer.parseInt("twelve");
        } catch (NumberFormatException e) {
            System.out.println("(NumberFormatException is unchecked, for convenience)");
        }
    }
}

try, catch, finally - and the resource problem finally solves

import java.io.*;

public class Resources {
    public static void main(String[] args) {
        // try-with-resources closes everything it opened, in reverse order,
        // even when the body throws - and it does not lose the original
        // exception if closing also fails.
        try (var reader = new BufferedReader(new StringReader("first\nsecond\n"))) {
            String line;
            while ((line = reader.readLine()) != null) {
                System.out.println("read: " + line);
            }
        } catch (IOException e) {
            System.out.println("io: " + e.getMessage());
        }

        // The same thing written by hand, which is what everyone got wrong
        // before Java 7: the close has to be in a finally, the finally can
        // throw, and that exception replaces the interesting one.
        BufferedReader reader = null;
        try {
            reader = new BufferedReader(new StringReader("third\n"));
            System.out.println("read: " + reader.readLine());
        } catch (IOException e) {
            System.out.println("io: " + e.getMessage());
        } finally {
            if (reader != null) {
                try {
                    reader.close();
                } catch (IOException ignored) {
                    // and here is where the real failure quietly disappears
                }
            }
        }
    }
}

Anything that implements AutoCloseable belongs in the parentheses. Writing the finally by hand is a bug waiting to be reported.

Your own exceptions, and what to put in them

public class Validation {
    // Unchecked, because a caller building a Registration has made a mistake -
    // and carrying the field means the caller can report it usefully.
    static final class ValidationException extends RuntimeException {
        // Every Throwable is Serializable, so javac -Xlint:serial asks for this.
        // It costs a line and stops the warning being one more thing to ignore.
        private static final long serialVersionUID = 1L;

        private final String field;

        ValidationException(String field, String reason) {
            super(field + " " + reason);
            this.field = field;
        }

        String field() { return field; }
    }

    record Registration(String email, int age) {
        Registration {
            if (!email.contains("@")) throw new ValidationException("email", "must contain @");
            if (age < 13) throw new ValidationException("age", "must be at least 13");
        }
    }

    public static void main(String[] args) {
        for (var input : new String[][]{{"ada@example.com", "36"}, {"nope", "30"}, {"a@b.com", "9"}}) {
            try {
                System.out.println(new Registration(input[0], Integer.parseInt(input[1])) + " accepted");
            } catch (ValidationException e) {
                System.out.println(input[0] + " rejected on " + e.field() + ": " + e.getMessage());
            }
        }
    }
}

Never swallow, and never lose the cause

public class Causes {
    static void load(String key) throws Exception {
        try {
            Integer.parseInt(key);
        } catch (NumberFormatException e) {
            // Wrap, and pass the original as the cause: the stack trace then
            // shows both where it failed and what you were trying to do.
            throw new Exception("cannot load key " + key, e);
        }
    }

    public static void main(String[] args) {
        try {
            load("abc");
        } catch (Exception e) {
            System.out.println(e.getMessage());
            System.out.println("  caused by: " + e.getCause());
        }

        // The one thing never to write:
        try {
            Integer.parseInt("abc");
        } catch (NumberFormatException ignored) {
            // silence. The program continues in a state nobody reasoned about.
        }
        System.out.println("(an empty catch block is how a bug becomes unfindable)");
    }
}

Optional, for a return value that may not exist

Optional is a return type. It says "there may be nothing here" in the signature, where a caller cannot miss it. It is not for fields and not for parameters.

import java.util.*;

public class Optionals {
    private static final Map<String, String> NAMES = Map.of("1", "Ada");

    static Optional<String> findName(String id) {
        return Optional.ofNullable(NAMES.get(id));
    }

    public static void main(String[] args) {
        System.out.println(findName("1").orElse("unknown"));
        System.out.println(findName("2").orElse("unknown"));

        findName("1").ifPresentOrElse(
                name -> System.out.println("found " + name),
                () -> System.out.println("nothing"));

        System.out.println(findName("1").map(String::toUpperCase).orElseThrow());

        // orElseGet defers the work; orElse builds the fallback every time,
        // even when it is not used.
        System.out.println(findName("1").orElseGet(Optionals::expensiveDefault));

        try {
            findName("2").orElseThrow(() -> new NoSuchElementException("no name for 2"));
        } catch (NoSuchElementException e) {
            System.out.println(e.getMessage());
        }

        // What NOT to write: this is `if (x != null)` with extra steps.
        var maybe = findName("1");
        if (maybe.isPresent()) System.out.println(maybe.get());
    }

    static String expensiveDefault() {
        System.out.println("  (building the default)");
        return "computed";
    }
}

Often the right answer is not to throw

An outcome the caller expects is a value, not an exception. Java's own library shows both spellings, and the second is usually the better neighbour.

import java.util.*;
import java.util.stream.*;

public class Values {
    static OptionalInt parse(String text) {
        try {
            return OptionalInt.of(Integer.parseInt(text));
        } catch (NumberFormatException e) {
            return OptionalInt.empty();
        }
    }

    public static void main(String[] args) {
        var inputs = List.of("10", "twelve", "30");

        int total = inputs.stream()
                .map(Values::parse)
                .filter(OptionalInt::isPresent)
                .mapToInt(OptionalInt::getAsInt)
                .sum();

        System.out.println(total);
        System.out.println(inputs.stream().map(Values::parse)
                .map(o -> o.isPresent() ? String.valueOf(o.getAsInt()) : "-")
                .collect(Collectors.joining(", ")));
    }
}

Exceptions are also expensive: building one captures the whole stack trace. One per bad row in a million-row import is a measurable cost as well as a confusing design.

What to take away

  • Checked for what a caller can recover from; unchecked for programming mistakes.
  • try-with-resources closes everything, in order, and keeps the real exception.
  • Carry the data the caller needs in your exception type, not just a message.
  • Always pass the cause when wrapping; never write an empty catch.
  • Optional is a return type - not a field, not a parameter.
  • orElseGet defers the fallback; orElse builds it whether or not it is needed.