Error Handling in APIs
Checked vs Unchecked Exceptions
Read aloud is unavailable in this browser.
The Great Exception Debate
Java's introduction of checked exceptions in 1995 sparked a debate that continues three decades later. The idea was compelling: if the compiler could force callers to handle certain exceptions, entire categories of bugs would be eliminated at compile time. Errors couldn't slip through unnoticed.
And yet, no major language designed after Java has adopted checked exceptions. C#, Kotlin, Scala, Swift, and TypeScript all chose unchecked-only models. Even within Java, influential voices argue checked exceptions were a mistake.
Why the controversy? Understanding the checked vs unchecked debate reveals deep insights about API design, the limits of static typing, and the nature of error handling itself.
By the end of this page, you will understand what distinguishes checked from unchecked exceptions, the arguments for and against each, when each is appropriate, and how to navigate this choice in your own API designs.
Mechanical Differences
Before evaluating tradeoffs, let's establish precisely what distinguishes checked from unchecked exceptions at the language level.
- •Must be declared in method signatures with
throws - •Callers must either catch or propagate
- •Compiler enforces handling at compile time
- •Represent recoverable conditions
- •Part of the method's public contract
- •Java examples:
IOException,SQLException,ParseException
- •Not required in method signatures
- •Callers may catch but aren't forced to
- •Only discovered at runtime if unhandled
- •Represent programming errors or unrecoverable conditions
- •Not part of the explicit contract
- •Java examples:
NullPointerException,IllegalArgumentException,RuntimeException
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
// CHECKED EXCEPTION: Compiler enforcementpublic class FileProcessor { // Checked: MUST be declared in throws clause public String readFile(Path path) throws IOException { return Files.readString(path); // Files.readString throws IOException } // Callers MUST handle checked exceptions public void processDocument(Path path) throws IOException { String content = readFile(path); // Must catch OR propagate // ... } // Alternative: catch and handle public void processDocumentSafe(Path path) { try { String content = readFile(path); // Process content } catch (IOException e) { log.error("Failed to read document: {}", path, e); // Handle the failure } }} // UNCHECKED EXCEPTION: No compiler enforcementpublic class Calculator { // Unchecked: NOT declared in throws (optional) public int divide(int a, int b) { if (b == 0) { throw new IllegalArgumentException("Divisor cannot be zero"); } return a / b; } // Callers are NOT forced to handle public void calculate() { int result = divide(10, 0); // Compiles fine! Throws at runtime System.out.println(result); // Never reached if exception thrown } // Can still choose to handle if desired public void calculateSafe() { try { int result = divide(10, getUserInput()); System.out.println(result); } catch (IllegalArgumentException e) { System.out.println("Invalid input: " + e.getMessage()); } }}In Java, the distinction is encoded in the class hierarchy: subclasses of Exception (but not RuntimeException) are checked; RuntimeException and its subclasses are unchecked. Error subclasses are also unchecked but represent serious system problems (OutOfMemoryError, StackOverflowError).
The Case FOR Checked Exceptions
Defenders of checked exceptions present compelling arguments about reliability, documentation, and forcing developers to confront failure modes explicitly.
- •Compile-Time Guarantees — You cannot accidentally forget to handle a checked exception. The compiler ensures every failure mode is addressed before the code runs. This catches entire categories of bugs during development.
- •Self-Documenting APIs — The throws clause is executable documentation. Readers immediately know which failures a method can produce. IDE tooling provides instant visibility into failure modes without consulting external docs.
- •Forces Conscious Decisions — Even if a caller decides to propagate an exception, they must do so explicitly. This conscious acknowledgment means failures were at least considered, not overlooked.
- •Failure Modes in the Contract — Checked exceptions make failure explicitly part of the API contract. Changes to failure behavior require signature changes, preventing silent contract breakage.
- •Recovery Encouragement — By requiring handling, checked exceptions encourage callers to actually recover from failures rather than just hoping they don't occur.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
// Benefit: API contract includes failure modespublic interface PaymentGateway { // The throws clause documents the contract: // - NetworkException: connectivity problems // - PaymentDeclinedException: card/account issues // - InvalidPaymentException: bad request data PaymentResult charge(PaymentRequest request) throws NetworkException, PaymentDeclinedException, InvalidPaymentException;} // Benefit: Compiler ensures callers address all failure modespublic class OrderService { public OrderResult placeOrder(Order order) throws OrderFailedException { try { // Compiler FORCES us to handle each checked exception PaymentResult payment = gateway.charge(order.getPaymentRequest()); return completeOrder(order, payment); } catch (NetworkException e) { // Recovery: retry with backoff return retryPayment(order, e); } catch (PaymentDeclinedException e) { // Recovery: notify customer, cancel order return handleDeclinedPayment(order, e); } catch (InvalidPaymentException e) { // Bug in our code—wrap and propagate throw new OrderFailedException("Invalid payment data", e); } }} // Benefit: Adding new failure mode breaks compilationpublic interface UpdatedPaymentGateway { // If we add FraudException, all callers MUST update PaymentResult charge(PaymentRequest request) throws NetworkException, PaymentDeclinedException, InvalidPaymentException, FraudException; // Adding this breaks existing callers at compile time!}From a type-theoretic view, checked exceptions are essentially union types. A method T foo() throws E has type T | E. The compiler enforces that callers handle both branches of the union, just as pattern matching on an ADT must be exhaustive.
The Case AGAINST Checked Exceptions
Critics of checked exceptions—including the designers of C#, Kotlin, and many senior Java engineers—present equally strong arguments about scalability, verbosity, and unintended consequences.
- •Signature Pollution — Checked exceptions propagate through call chains, polluting every intermediate signature. A method 5 layers up must declare exceptions from deep library code it knows nothing about.
- •Version Coupling — Adding a new checked exception breaks all callers. This creates pressure to never add exceptions (losing their value) or to use overly broad exception types.
- •Catch-and-Ignore Antipattern — Faced with mandatory handling, developers often write empty catch blocks or catch-and-log without recovery, defeating the purpose entirely.
- •Lambdas and Functional Pain — Checked exceptions don't compose with functional interfaces.
Stream.map()can't accept functions that throw checked exceptions without ugly workarounds. - •Recovery is Often Impossible — Many checked exceptions represent conditions callers cannot meaningfully recover from. Forcing handling adds ceremony without value.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556
// Problem: Signature pollution// Every layer must declare exceptions from layers belowpublic class Controller { public Response handle(Request r) throws ServiceException, RepositoryException, DatabaseException, NetworkException { return service.process(r); // Just passing through! }} // Problem: Developers defeat the mechanismpublic void processFile(Path path) { try { doWork(path); } catch (IOException e) { // THE ANTIPATTERN: Empty catch or useless logging e.printStackTrace(); // Logs but doesn't actually handle } // Code continues as if nothing happened—silent failure!} // Problem: Functional programming painpublic List<String> readAllFiles(List<Path> paths) { return paths.stream() .map(path -> { try { // IOException is checked—must catch inside lambda return Files.readString(path); } catch (IOException e) { throw new UncheckedIOException(e); // Wrap to propagate } }) .collect(Collectors.toList());} // Compare to how it reads without checked exceptions:// paths.stream().map(Files::readString).collect(toList()); // Problem: Over-broad typing loses precisionpublic void process() throws Exception { // Gave up on specificity // When throws clause becomes painful, developers use broad types // Now callers have no idea what actually might fail} // Problem: Can't meaningfully recoverpublic Config loadConfig() { try { return parseConfigFile(); } catch (IOException | ParseException e) { // What can we actually DO here? // Return default config? Exit? Throw runtime exception? // Often the only option is to propagate throw new ConfigurationException("Failed to load config", e); }}When checked exceptions become painful, developers often catch Exception broadly or wrap everything in RuntimeException. This defeats the entire purpose of checked exceptions while keeping the syntactic burden. If your codebase is full of these patterns, checked exceptions are hurting more than helping.
The Deeper Philosophical Divide
Beyond practical concerns, the checked vs unchecked debate reflects fundamental disagreements about software design philosophy.
| Question | Checked Exception View | Unchecked Exception View |
|---|---|---|
| Should the compiler enforce error handling? | Yes—prevent forgotten error handling | No—trust developers to handle appropriately |
| What is the cost of false safety? | Worth it for real bugs caught | Creates worse patterns (empty catches) |
| How predictable are recovery needs? | Library can identify recoverable errors | Only caller knows if recovery is possible |
| How stable are exception sets? | Evolution is gradual and manageable | Any change breaks the world |
| Are exceptions part of the contract? | Absolutely—failure is part of behavior | No—they're implementation details |
The Recoverability Question:
The crux of the debate often comes down to: who decides if an exception is recoverable?
Checked exception advocates argue the library author knows best. If FileNotFoundException is thrown, the caller might use a default file or prompt the user. The library signals "recovery is possible."
Unchecked exception advocates counter that only the caller knows their context. One caller might recover from FileNotFoundException; another might treat it as fatal. The library author cannot know in advance.
The Leaky Abstraction Problem:
Checked exceptions often violate abstraction boundaries. Consider:
interface UserRepository {
User findById(String id) throws RepositoryException;
}
This seems clean. But what is RepositoryException? It might wrap:
SQLException(database implementation)IOException(file-based implementation)HttpException(remote service implementation)
The interface leaks implementation details, unless all implementations use the same wrapped type—at which point, the specific exception type carries little semantic value.
Modern Best Practices in Java
Despite the controversy, Java still has checked exceptions. Here's how experienced Java developers navigate the reality:
- •Use checked exceptions only when callers can reasonably recover — If no reasonable recovery exists, use unchecked.
FileNotFoundExceptionwhen the caller specified the file? Checked.FileNotFoundExceptionfor an internal config file that must exist? Unchecked. - •Prefer few, domain-specific exception types — Rather than exposing implementation details (
SQLException), define domain exceptions (UserNotFoundException). This keeps signatures stable across implementation changes. - •Never catch and ignore — If you catch an exception, do something meaningful: log with context, transform, recover, or rethrow wrapped. Empty catches are bugs.
- •Consider unchecked for programming errors — Null arguments, invalid state, precondition violations—these indicate bugs, not runtime conditions.
IllegalArgumentException,IllegalStateException,NullPointerExceptionare rightly unchecked. - •Wrap at abstraction boundaries — When crossing abstraction layers, catch implementation-specific exceptions and rethrow as domain-appropriate exceptions.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
// Good: Domain exception hides implementationpublic class JdbcUserRepository implements UserRepository { @Override public User findById(String id) throws UserNotFoundException, RepositoryException { try { User user = jdbcTemplate.queryForObject( "SELECT * FROM users WHERE id = ?", userMapper, id ); if (user == null) { throw new UserNotFoundException(id); // Domain exception } return user; } catch (DataAccessException e) { // Wrap implementation exception in domain exception throw new RepositoryException("Failed to query user: " + id, e); } }} // Good: Unchecked for programming errorspublic void transfer(Account from, Account to, Money amount) { // Precondition violations are bugs—use unchecked Objects.requireNonNull(from, "from account cannot be null"); Objects.requireNonNull(to, "to account cannot be null"); if (amount.isNegative()) { throw new IllegalArgumentException("Transfer amount must be positive"); } // Business logic...} // Good: Checked when caller can recoverpublic class ImageProcessor { // Checked: caller provided the path, can handle missing file public Image loadImage(Path path) throws ImageNotFoundException { if (!Files.exists(path)) { throw new ImageNotFoundException(path); } // ... } // Unchecked: internal resources failing is a bug public void initialize() { try { loadDefaultProfile(); } catch (IOException e) { // Internal config should always exist—this is a bug throw new IllegalStateException("Missing required resource", e); } }} // Good: Meaningful handling, not empty catchpublic Optional<Config> tryLoadConfig(Path path) { try { return Optional.of(loadConfig(path)); } catch (ConfigException e) { log.warn("Config not found at {}, using defaults: {}", path, e.getMessage()); return Optional.empty(); // Actual recovery: use defaults }}How Other Languages Handle This
Examining how other languages approach the problem provides perspective on the design space and alternative solutions.
| Language | Approach | Philosophy |
|---|---|---|
| Java | Checked + unchecked | Compiler enforces recoverable; runtime for bugs |
| C# | Unchecked only | Trust developers; avoid signature pollution |
| Kotlin | Unchecked only | Java interop but cleaner; annotations available |
| Scala | Unchecked default | Functional style; Option/Either preferred |
| Swift | All throws marked | Must handle but can use try? or try! |
| Rust | No exceptions; Result<T, E> | Type system enforces; ? operator for ergonomics |
| Go | No exceptions; error values | Explicit handling; no hidden control flow |
1234567891011121314151617181920212223
// Kotlin: No checked exceptions, but can annotate for Java interop@Throws(IOException::class) // Only for Java callersfun readFile(path: Path): String { return Files.readString(path)} // Idiomatic Kotlin: return nullable or Resultfun findUser(id: String): User? { return userRepository.findById(id) // Returns null if not found} // Or use Result for richer error informationfun fetchUser(id: String): Result<User> { return runCatching { api.getUser(id) }} // Caller handles with when expressionval user = when (val result = fetchUser(id)) { is Result.Success -> result.value is Result.Failure -> handleError(result.exception)}Modern language design increasingly encodes errors in the type system (Rust's Result, Swift's throws, Kotlin's Result) rather than through checked exceptions. This achieves the compile-time safety goal without the scalability problems of throw declarations propagating through call chains.
Making the Decision for Your API
If you're designing an API in a language with both options (Java), or deciding on an error strategy in a language with alternatives (Result types), here's a decision framework:
- •Is this a condition callers will commonly need to handle differently? If yes, consider checked (or Result type). If callers will usually just propagate, use unchecked.
- •Is this a programming error? Null where null is forbidden, precondition violation, illegal state → unchecked. These are bugs to fix, not conditions to handle.
- •Does the condition cross abstraction boundaries? If the exception leaks implementation details, wrap in a domain exception regardless of checked/unchecked choice.
- •How will this interact with lambdas and streams? Heavy functional usage suggests unchecked or wrapper strategies to avoid cumbersome syntax.
- •What does your ecosystem expect? Match the conventions of frameworks and libraries you integrate with. Consistency reduces cognitive load.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950
// CHECKED: Caller commonly handles differentlypublic interface FileStorage { // FileNotFoundException: caller might use default, ask user, fail gracefully byte[] read(String key) throws FileNotFoundException, StorageException;} // UNCHECKED: Caller will usually just propagatepublic interface MessageQueue { // Connection failure is usually fatal—no point forcing handle/propagate cascade void publish(Message msg); // Throws QueueException (unchecked) on failure} // UNCHECKED: Programming errorpublic class ValidationResult { public void ensureValid() { if (!isValid()) { // This is a bug in the caller—they should check before calling throw new IllegalStateException("Cannot proceed with invalid result"); } }} // DOMAIN EXCEPTION: Wraps implementation detailspublic class UserService { public User getUser(String id) throws UserNotFoundException { try { return repository.findById(id) .orElseThrow(() -> new UserNotFoundException(id)); } catch (DataAccessException e) { // Don't expose DataAccessException (Spring/JDBC detail) throw new ServiceException("User lookup failed", e); } }} // FUNCTIONAL-FRIENDLY: Unchecked or wrapper for stream usagepublic List<ProcessedFile> processAll(List<Path> paths) { return paths.stream() .map(this::processFileUnchecked) // Returns ProcessedFile or throws .collect(Collectors.toList());} private ProcessedFile processFileUnchecked(Path path) { try { return processFile(path); } catch (IOException e) { throw new ProcessingException("Failed to process: " + path, e); }}KEEP LEARNING
There’s more to understand.
You’re reading the introduction. Create a free account to read the complete lesson, save notes and keep track of your progress.