Engineering

Error Handling and API Design

Exceptions, Optional, and sealed types — choosing the right error contract for every method

Learning Objectives

By the end of this module you will be able to:

  • Explain the difference between checked and unchecked exceptions in Java and articulate the design intent behind checked exceptions.
  • Describe Java's null handling evolution and the role of Optional in modern APIs.
  • Design a typed error hierarchy using sealed classes and pattern matching.
  • Compare Java's error handling to Rust's Result<T,E>, Python exceptions, and TypeScript's control-flow narrowing patterns.
  • Identify anti-patterns: swallowing exceptions, overusing checked exceptions, and misusing Optional.

Core Concepts

The error model is a philosophical stance, not just syntax

Every language makes an opinionated choice about what errors are. Go treats errors as regular return values to avoid invisible control flow — the implicit message is that errors are common, predictable, and the caller must face them. Exception-based languages treat errors as exceptional cases that interrupt normal flow. Type-system-encoded errors (Rust's Result<T,E>, Haskell's Either) make failure part of a function's type signature, forcing callers to acknowledge it structurally.

Java sits at a crossroads: it has exceptions (the dominant model), but it added Optional to address null, and modern Java can approximate value-based error modeling with sealed types. Understanding why Java made these choices prevents you from fighting the design and guides you toward the right idiom for each situation.

An API's failure mode is a critical part of its design contract with callers, similar to how changing a return type requires caller awareness.

Java's exception hierarchy

Throwable
├── Error          (JVM-level: OutOfMemoryError, StackOverflowError — never catch)
└── Exception
    ├── RuntimeException  (unchecked — not declared, not enforced)
    │   ├── NullPointerException
    │   ├── IllegalArgumentException
    │   ├── IllegalStateException
    │   └── ...
    └── checked exceptions (declared in throws clause — compiler-enforced)
        ├── IOException
        ├── SQLException
        └── ...

Checked exceptions must appear in a method's throws clause. The compiler rejects code that calls such a method without either catching the exception or re-declaring it. This is Java's compile-time mechanism for saying: this failure mode is recoverable and the caller must have a plan.

Unchecked exceptions (subclasses of RuntimeException) are not declared. They propagate silently up the call stack until caught or they crash the thread. They model programmer mistakes and invariant violations — things that should not happen in correct code.

Errors (Error hierarchy) are JVM-level catastrophes. You do not catch them in application code.


Null: Java's billion-dollar mistake and what comes after

Java references have always been nullable by default. This is widely acknowledged as a design flaw. There is no compiler-level enforcement that a reference is non-null before use, which makes NullPointerException one of the most common runtime failures in Java programs.

The evolution has moved in two directions:

  1. Optional<T> (Java 8): a container type that explicitly signals "this value may be absent." It is a return-type convention, not a general null replacement.
  2. Null-restricted value class types (Project Valhalla, preview): the Foo! syntax excludes null from a value class's value set entirely, enabling both safety and performance. A null-restricted storage slot requires no null-flag bit, allowing denser heap layouts.
Optional is a return type, not a field type

Optional should appear in method return types, not as field types, constructor parameters, or collection elements. Wrapping every nullable field in Optional produces noise without safety — it can still be null itself.


Modeling errors as values: sealed types

Java 17+ sealed classes, combined with pattern matching (switch expressions), let you model errors as a closed algebraic type — similar to Rust's Result<T, E> or a sum type in functional languages.

public sealed interface ParseResult<T>
    permits ParseResult.Ok, ParseResult.Err {

  record Ok<T>(T value)  implements ParseResult<T> {}
  record Err<T>(String reason) implements ParseResult<T> {}
}

The caller can then exhaustively handle both cases at compile time:

ParseResult<Integer> result = parseInteger(input);
String message = switch (result) {
    case ParseResult.Ok<Integer> ok   -> "Got: " + ok.value();
    case ParseResult.Err<Integer> err -> "Failed: " + err.reason();
};

The switch is exhaustive because the sealed interface has a finite, known set of permitted subtypes. The compiler will warn if a case is missing. This is analogous to what Rust enforces by default on Result and Option.


The expression problem and error hierarchies

When you define a sealed error hierarchy, you are choosing closed extensibility: the set of error variants is fixed at compile time, and adding a new variant requires modifying the sealed type. This is the FP side of the expression problem — it is easy to add new operations over a fixed set of cases, but hard to add new cases without touching existing code.

Exception hierarchies, by contrast, are open: any class can extend RuntimeException. This is the OOP side — new types (subtypes) can be added freely, but exhaustive handling is not enforceable. The right choice depends on who owns the error type and who extends it. For errors within a single service or library, sealed types give you exhaustiveness. For errors that third parties must extend, an open exception hierarchy is more appropriate.


Compare & Contrast

Java vs. Rust

DimensionJavaRust
Primary mechanismExceptions (checked + unchecked)Result<T, E> and ? operator
Null safetyOptional (convention), null-restricted types (preview)Option<T> enforced by compiler
Propagationthrows clause or silent (unchecked)Explicit ? or match
ExhaustivenessOnly with sealed types + switchAlways, for Result and Option
Performance costStack unwinding for exceptionsZero-cost error paths in Result
Checked at compile timeOnly checked exceptionsAll Result values

Rust's ? operator is roughly equivalent to re-throwing a checked exception, except it is syntax on the return type rather than control flow that escapes the normal path. In Rust, failure is in the type. In Java, failure is historically beside the type (the throws clause), or increasingly represented in the type via sealed Result-like wrappers.

Java vs. Python

Python has unchecked exceptions only. There is no compiler enforcement analogous to Java's checked exceptions. Python's convention is try/except around I/O and operations that are documented to raise. The absence of a throws declaration means the contract is informal — you rely on documentation and testing rather than the compiler.

Java's checked exceptions enforce the contract mechanically, which is both their strength and the source of their notorious misuse (empty catch blocks, catch (Exception e) {}).

Java vs. TypeScript

TypeScript has no checked exceptions and no throws annotation. Its approach to explicit error modeling uses:

  • Union return types: string | Error or { ok: true; value: T } | { ok: false; error: string }.
  • Type narrowing: after a conditional, the compiler refines the type.
  • Assertion functions (TypeScript 3.7+): functions that narrow types via control flow by throwing on failure — code after a successful assertion automatically has its type narrowed without an explicit branch.
  • any as escape hatch: TypeScript's any type disables type checking entirely for a variable, which is a pragmatic escape valve with no direct Java equivalent in mainstream usage.

Java's sealed Result-like types converge with TypeScript union types in intent — both push error representation into the return type and use control-flow analysis for exhaustiveness.


Key Principles

1. Checked exceptions = recoverable, expected, and worth forcing the caller to handle. Reserve checked exceptions for situations where a reasonable caller can do something meaningful about the failure (e.g., FileNotFoundException prompting a fallback path). If the failure is a programmer bug or a JVM-level issue, use unchecked.

2. Unchecked exceptions = programming errors and invariant violations. IllegalArgumentException, IllegalStateException, and NullPointerException signal that something is wrong with how the code is being called, not that an expected external condition has failed.

3. Optional is for return types, not field types. Its purpose is to make absence explicit at the call site. It is not a null-safety mechanism for object fields — it cannot prevent those fields from being null themselves.

4. Sealed error types are best for bounded, owned error domains. When you control the set of failure cases and want exhaustiveness guarantees, sealed types with switch expressions are the modern Java idiom. Think of it as the Java equivalent of Rust's Result.

5. Never swallow exceptions silently. An empty catch block or catch (Exception e) {} without logging is one of the most dangerous anti-patterns in Java. It makes errors invisible. At minimum, log the exception before discarding it.

6. Error messages must be actionable, not just readable. Academic research shows that error message usability depends not only on readability (length, tone, jargon) but on actionability — clear error locality and a precise problem description. Design your exception messages the same way: tell the caller what happened and where, not just that something went wrong.

7. Pure paths reduce error surface. Functions with no side effects are easier to reason about and test in isolation. When an error can only arise from a side effect (I/O, mutation), isolating the side effect to a narrow boundary means most of the logic can be tested without error-handling scaffolding.


Worked Example

Scenario: a service method that reads a user record from a database, parses a field, and applies a business rule. We will implement it three ways and discuss the trade-offs.

Version 1: Checked exception (traditional Java)

public UserSummary loadUserSummary(long userId)
    throws UserNotFoundException, DataParseException {

  UserRecord record = db.find(userId);     // throws UserNotFoundException
  int age = parseAge(record.ageField());   // throws DataParseException
  return new UserSummary(record.name(), age);
}

The caller is forced to handle or declare both exceptions. The failure modes are part of the method signature. This is the original Java intent: make recoverable failures explicit. The downside: every intermediate method in the call chain must also declare these exceptions unless it handles them.

Version 2: Unchecked exceptions (common in modern frameworks)

public UserSummary loadUserSummary(long userId) {
  UserRecord record = db.find(userId);   // throws UserNotFoundException (unchecked)
  int age = parseAge(record.ageField()); // throws DataParseException (unchecked)
  return new UserSummary(record.name(), age);
}

Spring, JPA, and most modern Java frameworks convert all persistence exceptions to unchecked. The call chain is cleaner. The downside: error handling is now implicit and easy to forget. You rely on a top-level handler (e.g., a Spring @ExceptionHandler) to catch and respond.

Version 3: Sealed result type (value-based, modern Java)

public sealed interface LoadResult
    permits LoadResult.Success, LoadResult.NotFound, LoadResult.ParseError {
  record Success(UserSummary summary) implements LoadResult {}
  record NotFound(long userId)         implements LoadResult {}
  record ParseError(String field, String raw) implements LoadResult {}
}

public LoadResult loadUserSummary(long userId) {
  Optional<UserRecord> maybeRecord = db.findOptional(userId);
  if (maybeRecord.isEmpty()) return new LoadResult.NotFound(userId);
  UserRecord record = maybeRecord.get();
  return switch (parseAge(record.ageField())) {
    case ParseResult.Ok<Integer> ok     ->
        new LoadResult.Success(new UserSummary(record.name(), ok.value()));
    case ParseResult.Err<Integer> err   ->
        new LoadResult.ParseError("ageField", record.ageField());
  };
}

The caller uses a switch that the compiler checks for exhaustiveness. No exception propagates. No try/catch required. The method signature tells the complete story. This is the pattern most similar to Rust's Result<T,E>.

When to prefer each version
  • Checked exceptions: when the failure is recoverable and you want the compiler to enforce handling. Good for library code where callers are unknown.
  • Unchecked exceptions: when using a framework that centralizes error handling, or when the error is a programming mistake.
  • Sealed result types: when you want exhaustiveness at the call site, when the caller must branch on the failure reason, or when you are designing a pure functional core that avoids exception-based control flow.

Common Misconceptions

"Checked exceptions are deprecated or bad practice." Checked exceptions are not deprecated and are still used throughout the Java standard library (IOException, SQLException, CloneNotSupportedException). The criticism is of overuse — wrapping every possible failure as checked — not of the mechanism itself. When a failure is genuinely recoverable and caller-facing, a checked exception communicates intent that unchecked exceptions cannot.

"Optional makes Java null-safe." Optional is a convention, not a safety net. It does not prevent null from being stored in fields, passed as arguments, or returned from methods that do not use it. The only mechanical null-safety coming to Java is via null-restricted value class types, which are still in preview.

"Sealed types replace exceptions." They do not. Exceptions and sealed result types serve different needs. Exceptions propagate across arbitrary call stacks without every intermediate method needing to handle them. Sealed result types require explicit propagation at every step — which is verbose for deep call chains and better suited to bounded contexts where the caller is closely related to the producer.

"An empty catch block is safe if nothing bad can happen." It is not. An empty catch block hides failures that can appear under conditions you did not anticipate. At minimum, log the exception. Silently discarding exceptions has caused significant production incidents where symptoms surfaced far from the cause, violating the error-aware design principle of failing fast and loud.

"Exception messages are just for developers, so format doesn't matter." Error message usability is measurable and affects how quickly problems are diagnosed and fixed. Research consistently shows that messages with clear error locality, precise problem descriptions, and actionable guidance reduce repair time. Treat exception messages as part of your API's contract with its operators.

Key Takeaways

  1. Java has three error layers. Error (JVM, don't catch), checked Exception (compiler-enforced, recoverable), and unchecked RuntimeException (programming bugs, framework-level handling). Each has a distinct intended use.
  2. The choice of error model is a philosophy. Exceptions model control flow interruption; value-based errors (sealed types, Optional) keep failure in the type. Java supports both, and modern idiomatic Java leans toward value-based approaches for bounded, owned error domains.
  3. Optional is a return-type contract, not a field-level null guard. Use it to communicate "this method may return nothing," not to replace null in object state.
  4. Sealed types + switch = Java's approximation of Result<T,E>. They give you compiler-checked exhaustiveness and make failure visible in the return type, converging with what Rust enforces by default.
  5. Never swallow exceptions; always write actionable messages. Silent exception handling and cryptic messages are anti-patterns with measurable impact on reliability and debuggability.

Further Exploration

Foundational Theory

  • The Error Model — Joe Duffy's exhaustive analysis of error model trade-offs across multiple languages and systems
  • Expression Problem — Philip Wadler's original formulation of the extensibility trade-off between open (exception) and closed (sealed) type hierarchies

Java Specifications

Language Comparisons

  • TypeScript: Narrowing — How TypeScript uses control flow analysis and assertion functions for error-adjacent type narrowing

Error Design Principles