Making Error Paths Visible: Learning from Rust's Type System

Introduction
Back when I first started with Python, my first web framework wasn’t Django or Flask — it was Tornado Web. I’m not sure of all the exact reasons why I started with it, but I’m thankful to this day that I did.. Tornado had this unique way of handling asynchronous operations, way before Python's asyncio came through.
In Tornado, you'd write async code like this:
@gen.coroutine
def async_function():
result = yield some_async_operation()
raise gen.Return(result)
Tornado's approach was a rather clever exploit of the language, but it revealed something deeper to me: return and raise are fundamentally the same thing. One is just conventionally used for success cases and the other for errors. You could think of one as the generalization of another, and a language would only need one of them. Let's examine two contrasting patterns that highlight this duality.
Return-only syntax
def div(a: float, b: float) -> float | ZeroDivisionError:
if b == 0:
return ZeroDivisionError("division by zero")
else:
return a / b
def main() -> None:
a = float(input())
b = float(input())
ans = div(a, b)
if isinstance(ans, ZeroDivisionError):
print("Oops, can't divide by zero!")
else:
print(f"Result: {ans}")
While the return-based approach offers explicit error handling, we can achieve similar results using Python's traditional exception mechanism. Here's how the same logic looks using raise.
Raise-only syntax
class Return(Exception):
def __init__(self, ans: float) -> None:
self.ans = ans
def div(a: float, b: float) -> None:
if b == 0:
raise ZeroDivisionError("division by zero")
else:
raise Return(a / b)
def main() -> None:
a = float(input())
b = float(input())
try:
ans = div(a, b)
except ZeroDivisionError:
print("Oops, can't divide by zero!")
except Return as e:
print(f"Result: {e.ans}")
As you can see above, raise
can be thought of one of multiple possible return types, and, similarly, return
can be thought just one more exception condition possible.
As this realization stayed with me, and as Python incorporated type annotations, it bothered me that while there was a clear syntax for annotating return types, there wasn’t one for annotating exceptions. Given how ubiquitous exceptions are in Python (and not really something exceptional) and pretty much the same thing from a more theoretical perspective, not having it in the signature of the function is similar as having the return type annotated with T | Any
:
def div(a: float, b: float) -> float | Any:
if b == 0:
return ZeroDivisionError("division by zero")
else:
return a / b
def main() -> None:
a = float(input())
b = float(input())
ans = div(a, b)
if isinstance(ans, float):
print(f"Result: {ans}")
else:
print(f"Oops! Who know what happened?")
Rust's Elegant Solution: A Unified Type System
These patterns reveal the fundamental similarity between returns and exceptions, but neither approach feels completely satisfactory. This is where Rust's type system offers an elegant solution. Instead of having separate mechanisms for success and error cases, or leaving error cases invisible in type signatures, Rust unifies everything into a single type system concept. It provides two main types for this purpose: Option<T>
for simple success/failure cases, and Result<T, E>
for cases where you want to specify what went wrong.
The Option Type: Simple Success or Failure
Let's start with the simpler case. Sometimes you just need to express "it worked" or "it didn't" without additional detail. In Python, you might write:
def safe_div(a: float, b: float) -> float | None:
if b == 0:
return None
return a / b
Rust makes this pattern more explicit and type-safe with Option<T>
:
enum Option<T> {
None,
Some(T),
}
fn safe_div(a: f64, b: f64) -> Option<f64> {
if b == 0.0 {
None
} else {
Some(a / b)
}
}
Result: When You Need More Detail
Remember our Python example where we returned either a float or a ZeroDivisionError? Rust's Result
type captures this pattern perfectly:
enum Result<T, E> {
Ok(T),
Err(E),
}
fn div(a: f64, b: f64) -> Result<f64, &'static str> {
if b == 0.0 {
Err("division by zero")
} else {
Ok(a / b)
}
}
Unlike our Python examples where we had to choose between using return or raise, or where error conditions weren't visible in type signatures, Rust's approach:
Makes all possible outcomes explicit in the type signature
Forces handling of both success and error cases
Unifies error handling into a single, consistent pattern
The Power of Exhaustive Matching
Where this really shines is in how Rust forces you to handle all cases:
match div(a, b) {
Ok(value) => {
println!("Division result: {}", value);
},
Err(message) => {
println!("Error occurred: {}", message);
},
}
If you forget to handle either case, the compiler will refuse to compile your code. This eliminates the kind of bugs we saw in our Python example where we had to remember to check the return type with isinstance()
.
The Duality of Returns and Errors: A Unified Perspective
These examples reveal a fundamental truth: functions can return multiple types of values - some representing success, others representing failure. The only real difference is in how we encode and handle these different paths.
We could do in Python the same as in Rust by using generics:
from typing import Generic, TypeVar
T = TypeVar('T')
E = TypeVar('E')
class Result(Generic[T, E]):
def __init__(self, value: T | E, is_ok: bool) -> None:
self._value = value
self._is_ok = is_ok
@classmethod
def ok(cls, value: T) -> 'Result[T, E]':
return cls(value, True)
@classmethod
def err(cls, error: E) -> 'Result[T, E]':
return cls(error, False)
def div(a: float, b: float) -> Result[float, str]:
if b == 0:
return Result.err("division by zero")
return Result.ok(a / b)
Similarly, for simpler cases where we just care about success or failure:
class Option(Generic[T]):
def __init__(self, value: T | None) -> None:
self._value = value
@classmethod
def some(cls, value: T) -> 'Option[T]':
return cls(value)
@classmethod
def none(cls) -> 'Option[T]':
return cls(None)
def safe_div(a: float, b: float) -> Option[float]:
if b == 0:
return Option.none()
return Option.some(a / b)
The Power of Making Paths Explicit
This approach of encoding all possible outcomes in types, whether implemented in Python, Rust, or any other language, has several benefits:
The function signature tells you everything about what the function might return, including error cases.
The compiler or type checker can verify that all cases are handled.
Error handling becomes a first-class concern in your code's architecture
There's no hidden control flow through exceptions
The result is code that's safer, clearer and more maintainable.
Pattern Matching Makes it Clean
One reason Rust's implementation of this pattern is particularly elegant is its pattern matching syntax, but we could implement something similar in Python using match statements (Python 3.10+):
def div(a: float, b: float) -> Result[float, DivisionByZeroError | OverflowError]:
if b == 0:
return Err(DivisionByZeroError())
if abs(a) > 1e308: # Python's float max
return Err(OverflowError())
return Ok(a / b)
match div(a, b):
case Ok(value):
print(f"Result: {value}")
case Err(DivisionByZeroError()):
print("Can't divide by zero")
case Err(OverflowError()):
print("Number too large")
Type Systems as Developer Tools
The real power of encoding error paths in types becomes apparent when working with modern development tools. Consider a large code base with multiple layers of abstraction:
# Low level database function
def fetch_from_db(user_id: str) -> Result[dict, ConnectionError | NotFoundError]:
# Simulating DB access...
if user_id == "":
return Err(ConnectionError())
if user_id == "404":
return Err(NotFoundError())
return Ok({"id": user_id, "name": "John"})
# Mid level business logic
def validate_user(data: dict) -> Result[dict, ValidationError]:
if "name" not in data:
return Err(ValidationError())
return Ok(data)
# High level workflow
def get_user(user_id: str) -> Result[dict, ConnectionError | NotFoundError | ValidationError]:
# The IDE will help us handle all error cases from lower levels
db_result = fetch_from_db(user_id)
match db_result:
case Ok(data):
return validate_user(data) # ValidationError automatically becomes part of our error type
case Err(ConnectionError()):
return Err(ConnectionError()) # Pass through the lower level error
case Err(NotFoundError()):
return Err(NotFoundError())
# Usage with pattern matching
match get_user("some_id"):
case Ok(user):
print(f"Found user: {user}")
case Err(ConnectionError()):
print("Could not connect to database")
case Err(NotFoundError()):
print("User not found")
case Err(ValidationError()):
print("Invalid user data")
With proper type annotations:
IDE Support: Tools like PyCharm can:
Warn if you forget to handle an error case
Show you exactly what types of errors each function might return
Provide autocomplete for error handling patterns
Track error types through complex call chains
Refactoring Safety: When you change an error type in one function, the IDE can highlight every place that needs to be updated to handle the new error type.
Documentation at Your Fingertips: Hover over any function to see not just what it returns on success, but all the ways it might fail.
Contrast with Traditional Exceptions
Consider how this differs from traditional exception handling in a large code base:
# Traditional exception approach
def fetch_from_db(user_id: str) -> dict:
# What can be raised here? Need to check implementation or docs
if user_id == "":
raise ConnectionError("Database unavailable")
if user_id == "404":
raise KeyError(f"User {user_id} not found")
return {"id": user_id, "name": "John"}
def validate_user(data: dict) -> dict:
if "name" not in data:
raise ValueError("Invalid user data")
return data
def get_user(user_id: str) -> dict:
try:
data = fetch_from_db(user_id)
return validate_user(data)
except (ConnectionError, KeyError, ValueError) as e:
# Easy to miss an exception type
# Easy to catch too many with 'except Exception'
# Error handling tends to get condensed into a single case
log.error(f"Failed to get user: {e}")
raise # What exactly are we raising here?
# Usage:
try:
user = get_user("some_id")
print(f"Found user: {user}")
except Exception as e: # Often degrades to catch-all
print(f"Something went wrong: {e}")
Without error types in the signatures:
IDEs can't help you identify possible errors
Documentation about error cases tends to get out of date
It's easy to accidentally catch too many or too few exceptions
Error handling patterns become inconsistent across a code base
Refactoring error handling becomes risky and time-consuming
By making error paths explicit in our types, we turn the type system into a powerful tool for managing complexity in large code bases. The compiler and IDE become active participants in maintaining consistent and complete error handling throughout the system.
Beyond Language Boundaries
While Rust's implementation of this pattern through Option<T>
and Result<T, E>
is particularly well-designed, the underlying concept is universal. Any language with a type system can implement this pattern, and doing so brings many of the same benefits:
Error cases become visible in function signatures
The type system helps ensure proper error handling
Control flow becomes more explicit and easier to follow
Code becomes more self-documenting
Whether we're working in Python, TypeScript, Java, or any other language, we can learn from this approach and apply its principles to write more reliable and maintainable code.
Practical Challenges in Adoption
However, adopting this pattern isn't without its trade-offs. Teams with developers deeply familiar with traditional exception handling might find this approach initially counterintuitive. The learning curve can be particularly steep for junior developers who are already grappling with basic programming concepts. Additionally, introducing a new error handling pattern in an established codebase can lead to inconsistency if not implemented systematically across the entire project. Teams need to carefully weigh these practical considerations against the long-term benefits of more explicit error handling.
Conclusion
Throughout this exploration of error handling patterns, we've seen how making exceptions part of function signatures transforms them from invisible control flow into explicit, manageable parts of our program's type system. This approach isn't just about cleaner code—it's about building more reliable systems where error cases receive the same careful consideration as success paths.
The benefits of this approach extend beyond individual functions to entire code bases. When errors are part of our type signatures, we gain powerful tools for static analysis, better IDE support, and clearer documentation. Our type checkers and development tools become active participants in ensuring we handle errors consistently and comprehensively.
While the implementation details may vary across languages, the core principle remains: errors are not exceptional, they're essential parts of our program's logic. By making them explicit in our function signatures, we not only make our code more maintainable but also create systems that are more robust and easier to reason about.
Whether you're working in Python, Rust, or any other language, consider how making error types explicit in your function signatures might improve your code's reliability and maintainability.
Subscribe to my newsletter
Read articles from Leandro Lima directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
