An exception can make a recoverable failure invisible in an API’s return type. The caller sees the happy path, the compiler approves it, and an uncommon input later jumps to a catch block the caller may not have written.
Our rule at Momento was deliberately strong: model expected failures as return values at component boundaries. That costs a little more code where the API is defined, but it gives every caller—and the compiler—the information needed to handle each known outcome. Rust and Kotlin developers may find themselves nodding along.
Why exceptions became a design problem for me
I started out professionally with C# and Java. In those days, generics were new and not widely adopted. A common debate was “exceptions: checked or unchecked?” Passionate and respected people differed greatly, and the outcome—at least in Java—was a uniform mess.
I cannot count the bugs I resolved because someone forgot to catch a relevant exception, or because a new exception type appeared without every consuming call site and stack being inspected. As an early contribution, I wrote a logAndThrow() function. I then spent years passionately advocating for its removal because it codified one of the worst ways to handle exceptions.
Later, I spent a few years deep in C++. Exceptions were problematic for our work, and we disabled them to our great benefit. That experience, along with the ever-present possibility of a segmentation fault, set the stage for my return to the Java Virtual Machine (JVM) at Momento.
A greenfield service made the tradeoff concrete
At the time, Momento used gRPC and, for many internal services, Kotlin. Kotlin compiles to JVM bytecode, much as TypeScript compiles to JavaScript.
While setting the direction for a greenfield service, I wanted mundane mistakes to become difficult to write. I did not want every developer—including future me—to read every line of every dependency just to discover what needed a catch block. That approach spends engineering time on defensive archaeology instead of the service.
The alternative was to put known outcomes in the API contract and let the compiler help callers account for them.
Make common mistakes fail at compile time
This approach starts with two technical maxims:
- Software should lean on the compiler to reject illegal code.
- Common bugs should be illegal to write. Functional programmers, unite!
A simple example is a compiler preventing you from using an uninitialized variable. This guardrail is common across the JVM, .NET, Rust, and other ecosystems. Within the language’s safety boundaries, the compiler rejects code that could read an uninitialized variable. Developers then have to fix the code before it runs.
var message: String? = null
if (condition) {
message = "the condition was true"
} else {
message = "the condition was false"
}
Kotlin’s type system also represents possible nullability. We can remove that unnecessary state and lean further on the compiler:
val message: String
if (condition) {
message = "the condition was true"
} else {
message = "the condition was false"
}
Declaring message with the read-only val rather than var means that the compiler must prove it was initialized before use. If the compiler cannot prove that, the program does not compile. Good.
Kotlin lets us go one step further and make the conditional itself an expression:
val message = if (condition) {
"the condition was true"
} else {
"the condition was false"
}
A throw hides control flow from callers
Oh yeah, this is supposed to be about exceptions. Exceptions are, if you squint—and I don’t think you need to squint very hard—goto by another name.
One trick I’ve seen from teams that decry goto is the do {} while (false); pattern. It gives you break and continue labels that behave like constrained jumps. Future readers still have to reason about the scopes and original intent. A dynamic break counter that bounces out of successive wrapped loops makes that task even worse.
To be fair, the problem is not the exception object. A stack trace bundled with helpful debug information is useful. The problem is the throw or raise statement.
When library code throws, control jumps to the nearest compatible catch block on the stack. The API’s return type does not tell callers where that block needs to be—or whether anyone provided it. That hidden path is the bug this philosophy targets.
Consider an API you might provide to teammates, customers, or future you. It returns a random number most of the time, but it distrusts values near the upper limit:
fun randomNumberUsually(): Int {
val randomNumber = ThreadLocalRandom.current().nextLong()
if (randomNumber < Long.MAX_VALUE - 32) {
return 4 // source: xkcd.com/221
} else {
throw AskMeAgainException("this function does not trust random values near 2^63")
}
}
delay(randomNumberUsually())
The call site ignores the API’s distrust of large values from nextLong(). On that rare path, the exception escapes and may crash the caller.
Instead, expose both expected outcomes through the type system:
sealed interface RandomNumberUsually {
data class RandomNumber(val n: Int) : RandomNumberUsually
object AskMeAgainLater : RandomNumberUsually // You could make this a data class and put an exception (unthrown of course) inside if you want
}
fun randomNumberUsually(): RandomNumberUsually {
val randomNumber = ThreadLocalRandom.current().nextLong()
return if (randomNumber < Long.MAX_VALUE - 32) {
RandomNumberUsually.RandomNumber(4) // source: xkcd.com/221
} else {
RandomNumberUsually.AskMeAgainLater
}
}
This takes more code to write. In return, the API exposes every expected outcome to the type system. The naive delay(randomNumberUsually()) call no longer compiles because the caller has not decided what to do with the rare large-value case.
The caller must handle both cases:
delay(
when (val random = randomNumberUsually()) {
is RandomNumberUsually.RandomNumber -> random.n
RandomNumberUsually.AskMeAgainLater -> 16 // oh well, I don't think this is so bad
// There are no more possible responses. The compiler will remind me if the return codes are updated and another is added.
}
)
The caller can use an else -> branch when only one result needs special treatment, but the choice remains visible. No expected path can jump to an unknown catch block. A truly unexpected bug, such as ThreadLocalRandom::current() failing, may still escape. If that failure becomes an expected part of operation, the component can catch and model it as another type-safe return value.
By contrast, callers who discover AskMeAgainException after a failure have to retrofit a catch block:
delay(
try {
randomNumberUsually()
} catch (e: AskMeAgainException) { // Are there other exceptions I should catch? Remember to check on every version bump...
16 // this caused an outage because it crashed the server...
}
)
Model expected failures as return values
Your errors are not special; they are return values. Never raise exceptions across component boundaries.
That is the rule we adopted. Components should be safe to invoke without wrapping every call in try {}. Known failure modes belong in their return types. Bugs that the component did not anticipate can propagate outward and be handled once at the top, where the program can crash or log an unhandled defect.
Within a component, you might use language features or libraries that throw exceptions. That is fine. Catch the exceptions that represent expected failures, then model the component’s outcomes. In the general case, Ok(value) and Error(exception) may be enough. Returning an error gives the caller a choice; throwing it makes the control-flow decision for them.
What counts as a component is a judgment call. It could be a method, function, class, or something else. In general, tighter boundaries give the compiler more opportunities for static verification.
The pattern across languages
Historical context: This language-feature snapshot reflects the ecosystem in 2022. Consult each language’s current documentation before applying it to a new design.
Some languages already had built-in affordances for this approach, while others were expanding their support. I certainly did not invent the idea.
In Rust, Result<> and Option<> make the alternatives explicit. You can call .unwrap() and panic, but the ? operator lets a possibly failing function return a Result to its caller. It is a specialized form of pattern matching for error propagation, creating a visible, stackwise path until something handles the failure.
Go treats errors as values, and that choice shapes its idioms. Its error type lets a function advertise its error semantics. Whatever else you think of the language, I like that philosophy.
C# 8.0 added pattern matching to switch expressions. Its enum behavior is not the model I would choose, but the pattern-matching support makes the language better suited to explicit return variants.
Java also embraced pattern-matching constructs with its structured instanceof operator. Although useful for many kinds of downcasting, it also helps callers handle explicit API variants predictably.
The C++ community was also working on pattern-matching constructs. In the meantime, variants such as std::get and std::holds_alternative offered a way to represent alternatives. The design discussion is subtle, but Herb Sutter’s pattern-matching demonstration is worth your time. It also discusses the C# implementation of switch, tying the language examples back to the design idea in this post.
The tradeoff: more modeling, fewer surprises
What this buys callers
- Callers can have fewer unhandled expected failures because the compiler verifies that every represented path is handled.
- Callers discover and account for edge cases earlier.
- Callers do not have to scatter
try/catchblocks around APIs that may throw. - Usage sites make expected outcomes explicit, and the compiler enforces that contract where the language supports exhaustive matching.
What it costs
- API authors sometimes write more code because they must choose sealed types and model the function’s return domain.
- Callers may also write more. IDEs can generate pattern-match arms in many languages, but callers still have to decide what to do with the failure case.
- Some languages do not offer a concise pattern-matching model.
- Some developers prefer exceptions. This philosophy does not prevent an API from offering a
getOrThrow()convenience onRandomNumberUsually.
Give callers the choice
When you are deep in one component, a network error or slow disk might seem like the end of the world. At a higher layer, the caller may have a fallback—or a different view of how important that failure is.
Calling unwrap() on an Option that might be None forces a panic on library users who may have a workable fallback. The failure matters, but it is not so special that the library should decide to crash the caller.
For the cost of some additional modeling, your users—teammates, customers, and future you—gain code whose known outcomes are visible before it runs. The compiler cannot make a system correct, but it can make this class of surprise harder to ship.
If you have a different boundary for exceptions, join the Momento Discord and tell us where you draw it.