In recent years, it has become almost cliché to say that “exceptions are outdated” and that results and typed errors are the future.
But many of these discussions miss the point: this isn’t about syntax or hype. It’s about how programming languages model failure — whether as a control-flow event or as part of a function’s type contract.
Note: This discussion covers only expected and recoverable errors, such as validation failures, I/O issues, or other operations that can legitimately fail. Unpredictable conditions that usually terminate execution — like panic! in Rust or RuntimeException in Java — are not considered here. These fall into the category of unrecoverable failures, which follow a different handling philosophy.
Two opposite philosophies: Go and Java
To see where this all started, let’s compare two opposite schools of thought.
Go — errors as plain values
Go takes a deliberately simple approach: no exceptions, no stack unwinding, no compiler magic.
Every function that can fail returns a second value — an error — and you decide what to do with it.
1func saveUser(u User) error {
2 if err := validate(u); err != nil {
3 return err
4 }
5 if err := persist(u); err != nil {
6 return err
7 }
8 return nil
9}
10
11func main() {
12 if err := saveUser(user); err != nil {
13 log.Println(err)
14 }
15}
Advantages
- Predictable and lightweight: an error is just another return value.
- No hidden control flow — everything is explicit.
- Easy to reason about, especially for newcomers.
Disadvantages
- Verbose: the repeated
if err != nil
adds noise. - Harder to compose: error handling doesn’t chain naturally.
- Limited typing:
error
is an interface, not a generic type — so you lose precision. - Minor runtime cost: each error is an interface value that stores type metadata and a pointer, which adds some indirection. Not dramatic, but not free either.
The key trade-off in Go’s model is simplicity over abstraction. You always know exactly what happens, but you pay for it in verbosity and lack of composability.
Java — checked exceptions and enforced handling
Java’s design made the opposite bet: failures should be part of the function’s contract.
The compiler forces you to either handle or declare exceptions with throws
.
1public void saveUser(User user) throws ValidationException, SQLException {
2 validate(user);
3 persist(user);
4}
5
6public static void main(String[] args) {
7 try {
8 saveUser(new User());
9 } catch (ValidationException | SQLException e) {
10 log.error(e.getMessage());
11 }
12}
Advantages
- The compiler enforces discipline: you can’t ignore a declared failure.
- Method signatures explicitly show which errors might occur.
Disadvantages
- Verbose and intrusive:
try/catch
spreads across layers. - Performance cost: throwing exceptions triggers stack unwinding. That means the runtime must walk back through the call stack until it finds a matching
catch
block, executing cleanup code (finally
) along the way. This involves capturing stack traces and invalidates optimizations — a relatively expensive process. - Split semantics: the return type (
void
,User
, etc.) doesn’t represent failure — it’s a separate control channel.
Newer versions of Java (17+) have started experimenting with pattern matching for exceptions, and projects like Project Amber are introducing more expression-oriented handling.
However, these improvements are opt-in features — they make exceptions less painful, but don’t change the core model. Exceptions in Java are still runtime control flow, external to the type system.
Rust: a middle ground that made it practical
Rust sits between those two worlds.
Like Go, it rejects exceptions as a control mechanism. Like Java, it makes failure handling explicit and type-checked by the compiler.
Everything revolves around the Result<T, E>
type:
1fn save_user(user: &User) -> Result<(), AppError> {
2 validate(user)?;
3 persist(user)?;
4 Ok(())
5}
The ?
operator is shorthand for:
1match validate(user) {
2 Ok(_) => (),
3 Err(e) => return Err(e.into()),
4}
This is functionally equivalent to Go’s if err != nil { return err }
, but with strong typing and zero runtime overhead — the compiler expands it to a simple conditional return.
There’s no stack unwinding, no heap allocation, no dynamic dispatch. Errors are just data, represented as enums fully known to the compiler.
What Rust borrows from each model
Aspect | Java | Go | Rust |
---|---|---|---|
Errors are part of the function’s contract | yes (throws ) |
yes (error return) |
yes (Result<T, E> ) |
Stack unwinding for normal flow | yes | no | no |
Compiler-enforced handling | yes | no (discipline only) | yes |
Runtime cost | high | low (interface indirection) | negligible (compile-time dispatch) |
Explicit control flow | no (implicit throw) | yes | yes |
Verbosity | high | high | low |
Conceptually, Rust is closer to Java in safety guarantees — you can’t accidentally ignore an error — but closer to Go in semantics, since an error is still a return value, not a runtime jump.
When Rust’s model isn’t perfect
Rust’s approach has trade-offs of its own.
Because every possible failure must appear in the type system, functions can quickly become cluttered with deeply nested Result
and Option
types. Generic error composition (From
/Into
or thiserror
) mitigates this, but adds abstraction overhead that’s not always worth it for small programs.
Rust also lacks the “bail out anywhere” flexibility that exceptions provide — sometimes a throw
really is the cleanest way to exit multiple layers of logic. Go’s simplicity can also feel more natural in scripts, tooling, or APIs where errors are logged and ignored locally.
So while Rust gives maximum safety and composability, it also asks the developer to model every failure explicitly — a strength for large systems, but arguably overkill for small ones.
Semantics and visibility
The key difference lies not in whether the language forces handling, but in how much the compiler knows about the error.
- In Java, both
User save()
andUser save() throws SQLException
return the same type (User
); the error channel exists outside the type system. - In Rust,
fn save() -> Result<User, DbError>
is a distinct type — the compiler tracks every propagation through the call chain.
That’s why Rust’s approach composes so well: you can map, transform, or merge errors as first-class values, without the language having to “jump” out of your function.
The bigger picture
None of these models are universally “better.” Each one optimizes for a different balance of safety, simplicity, and expressiveness.
Language | Philosophy | What it optimizes for |
---|---|---|
Java | Failures as control flow | Safety through enforcement, at the cost of performance and verbosity |
Go | Failures as values | Simplicity and predictability, at the cost of repetition |
Rust | Failures as typed values | Compile-time safety and composability, at the cost of complexity and verbosity in large type signatures |
Over time, programming languages have been converging toward one principle: make failure explicit, predictable, and type-aware.
Rust didn’t invent that idea — it just gave it a practical, ergonomic shape that fits modern systems programming.