Exceptions Are Hidden Return Values – And That’s a Problem

Exceptions are one of the most widely adopted error-handling mechanisms in modern programming languages. They are convenient, expressive, and deeply integrated into the runtime of languages such as Java, C++, and C#.

But there is a fundamental problem with using exceptions to represent failures: they introduce additional exit channels into functions, outside their declared return type.

And that makes code harder to understand, reason about, test, and maintain.

A function should have an explicit contract

Consider a simple Java method:

public Account withdraw(Account account, Money amount) {
    // ...
}

When reading this signature, we know what the function returns: an Account.

But do we know all the possible outcomes?

Not necessarily.

The method might throw an InsufficientFundsException, an IllegalStateException, a NullPointerException, or any number of other runtime exceptions.

The actual contract is therefore not simply:

  • Input: Account, Money
  • Output: Account

It is closer to:

  • Input: Account, Money
  • Output: Account, or potentially any unchecked exception.

The signature tells us only part of the story.

This is particularly problematic because a caller cannot determine, by looking at the method signature, which exceptional outcomes it should expect or handle.

Checked exceptions don’t solve the fundamental problem

Java attempted to address this issue with checked exceptions.

Consider:

public Account withdraw(Account account, Money amount)
        throws InsufficientFundsException {
    // ...
}

At least now the compiler forces callers to acknowledge InsufficientFundsException.

But we have introduced a second completion channel.

The method can either return an Account or terminate by throwing an InsufficientFundsException. These are two distinct possible outcomes, and both are part of its contract.

Conceptually, the method has two possible result types:

Account | InsufficientFundsException

Of course, Java doesn’t represent them as a single return type. Instead, it uses a separate language mechanism.

This separation is precisely the problem.

And checked exceptions don’t cover everything. Runtime exceptions can still escape without being declared, meaning the actual set of possible outcomes can be larger than the signature suggests.

We have traded some uncertainty for a different kind of complexity, without eliminating the underlying issue.

Why not make failures part of the return type?

There is a much more explicit way to model multiple possible outcomes: represent them in the return type itself.

Languages with algebraic data types and libraries providing Either and Result types make this possible.

For example, using the Vavr library in Java, instead of throwing an exception when an account has insufficient funds, we could write:

import io.vavr.control.Either;

public Either<WithdrawalError, Account> withdraw(
        Account account,
        Money amount) {
    if (account.balance().isLessThan(amount)) {
        return Either.left(new InsufficientFunds());
    }

    return Either.right(account.withdraw(amount));
}

The function returns one of two explicitly declared outcomes:

  • A successful result: Account
  • A business failure: WithdrawalError

The caller cannot pretend that the second outcome doesn’t exist. It is part of the function’s return type.

In a language with exhaustive pattern matching, the compiler can also help ensure that every possible outcome is handled.

For illustration, the following is pseudocode rather than Java:

match withdraw(account, amount) {
    Right(updatedAccount) -> save(updatedAccount)
    Left(InsufficientFunds) -> rejectWithdrawal()
}

There is no hidden exit channel. There is no surprise exception waiting to escape at runtime.

The function’s contract describes its possible business outcomes, and the type system can help enforce that contract.

Business errors are not exceptional circumstances

Perhaps the most important distinction is between business failures and system failures.

Consider these examples:

  • An account has insufficient funds.
  • A customer is not eligible for a discount.
  • A requested operation violates a business rule.
  • An invoice cannot be issued because a required field is missing.

Are these exceptional circumstances?

Not really. They are ordinary outcomes of business operations.

A withdrawal can succeed or fail because of insufficient funds. Both outcomes are legitimate possibilities within the business domain.

A customer can be eligible or ineligible for a discount. Both are valid results of evaluating the business rules.

Calling these outcomes exceptions doesn’t make them exceptional. It simply forces us to express normal business decisions through a mechanism designed to interrupt normal program execution.

Business logic should explicitly model these outcomes.

If a business operation can fail for a known reason, that reason should be represented in its return type.

This has another important consequence: business errors become part of the domain model rather than implementation details of the language’s exception mechanism.

They can be documented, tested, transformed, composed, and handled intentionally.

What about system failures?

There is a common argument against this approach: system failures are different.

Network connections can drop. Disks can become unavailable. Database servers can crash. Operating system calls can fail.

These are real failures, and we cannot simply ignore them.

But why should they require a different mechanism for leaving a function?

There is no fundamental reason.

A network request can return a successful response or a connection error. A database operation can return a result or a database error. A file operation can return its contents or an I/O error.

All these outcomes can be represented in types.

And we don’t need a functional programming language to do this. Java can express exactly the same idea using Either or Result types.

For example, using Vavr:

import io.vavr.control.Either;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

public Either<IOException, String> readFile(Path path) {
    try {
        return Either.right(Files.readString(path));
    } catch (IOException e) {
        return Either.left(e);
    }
}

The method explicitly declares both possible outcomes:

  • A successful result containing the file contents.
  • An IOException describing why the operation failed.

The exception is caught at the boundary where the system interaction happens and converted into an ordinary, typed result.

From that point on, callers don’t need to know about Java’s exception mechanism. They can compose operations, inspect results, and decide how to handle failures using the same mechanism they use for business-level errors.

This is the important distinction: the fact that a failure originates in the operating system or an external service doesn’t mean it needs a different representation in application code.

We know that networks fail. We know that disks fail. We know that external services become unavailable.

Their occurrence may be unpredictable, but their possibility is not.

And if a function can produce a particular observable failure, its contract should communicate that possibility.

There is no fundamental reason to give system failures a separate exit channel from successful results.

Exceptions can still be a valid architectural mechanism

There are programming models where exceptions have a legitimate and important role.

Erlang is a particularly interesting example.

Its concurrency model uses lightweight BEAM processes, isolation, and supervision trees. Process failures and exception handling are first-class architectural concepts.

A process can fail, and a supervisor can detect its termination and apply a configured restart strategy.

This is an explicit fault-tolerance strategy built into the runtime and programming model.

It is fundamentally different from using exceptions to represent ordinary business outcomes.

A customer cannot withdraw money because their account has insufficient funds. That is a business result.

A BEAM process crashes because an unexpected failure prevents it from continuing. That is a process failure managed by the supervision architecture.

These are different concerns, and they deserve different handling strategies.

The existence of such programming models doesn’t invalidate the argument. It reinforces the importance of distinguishing between domain-level outcomes and runtime-level fault management.

Explicit contracts lead to more predictable systems

Why does this distinction matter so much?

Because software becomes harder to reason about when functions can exit through channels that aren’t visible in their signatures.

When a function returns a Result or Either, its possible outcomes are explicit.

A caller can inspect those outcomes, decide what to do, and propagate them to another operation.

When a function throws an unchecked exception, the caller might not even know that such an outcome is possible.

This creates several problems:

  • Incomplete error handling: callers may handle the expected return value while overlooking an unchecked exception.
  • Hidden dependencies: understanding a function’s behavior requires reading its implementation and potentially the implementations of everything it calls.
  • Fragile refactoring: changing an internal implementation can introduce new runtime exceptions that callers were never designed to handle.
  • Less reliable tests: tests may verify the declared return values without covering all the possible exceptional execution paths.

None of these problems is inevitable, and exceptions can certainly be handled carefully. But the language mechanism makes it easier to leave parts of the contract implicit.

Explicit result types move that responsibility into the type system.

They make the code communicate its intentions rather than relying on developers to discover them.

This is particularly valuable in small engineering teams, where code is frequently maintained by people other than its original authors and where there may not be enough resources for exhaustive testing.

A compiler that can help identify unhandled outcomes is a valuable safety net.

It doesn’t eliminate bugs, but it can prevent entire categories of mistakes before the application runs.

The type system should describe the whole contract

Exceptions are useful. They are deeply integrated into many programming languages, and some programming models even depend on them for their fault-tolerance mechanisms.

But usefulness doesn’t make them the right abstraction for representing the possible outcomes of a function.

A function’s observable behavior should be reflected in its type.

If it can return an account or an insufficient-funds error, its return type should say so.

If it performs I/O, its type should communicate that effect.

If it can produce a network error, that possibility should be represented explicitly rather than escaping through an undeclared control-flow channel.

There is no fundamental reason to give failures a separate exit channel from successful results.

The goal is not to make error handling more complicated. It is to make the actual contract of a function visible, intentional, and enforceable.

A function’s signature should tell us what it can do and what outcomes it can produce. Not just what happens when everything goes right.