Result Types and Error Handling Without Exceptions
Why Use Result Types?
Exception-based error handling forces you to wrap code in try/except blocks. It’s easy to forget handling an error, which leads to runtime crashes. Result types make error states explicit in your function’s type signature.
Compare these two approaches:
# Exception-based — errors are hidden in the type
def divide(a: int, b: int) -> int:
if b == 0:
raise ValueError("Cannot divide by zero")
return a // b
# Result-based — errors are part of the type
from result import Ok, Err, Result
def divide_result(a: int, b: int) -> Result[int, str]:
if b == 0:
return Err("Cannot divide by zero")
return Ok(a // b)
The caller knows immediately that this function can fail. The type Result[int, str] tells you it returns an integer on success or a string error on failure.
Basic Usage
Install the library first:
pip install result
Create Ok and Err values:
from result import Ok, Err, Result
# Success case
ok_value = Ok(42)
# output: Ok(42)
# Failure case
err_value = Err("something went wrong")
# output: Err('something went wrong')
Check which case you have:
from result import is_ok, is_err
result = Ok(10)
is_ok(result) # True
is_err(result) # False
Access the underlying values:
ok_value = Ok(42)
err_value = Err("oops")
ok_value.ok_value # 42
err_value.err_value # "oops"
ok_value.ok() # 42
err_value.ok() # None (safe accessor)
Pattern Matching (Python 3.10+)
Use match statements for readable error handling:
from result import Ok, Err, Result
def divide_result(a: int, b: int) -> Result[int, str]:
if b == 0:
return Err("Cannot divide by zero")
return Ok(a // b)
for a, b in [(10, 2), (10, 0), (15, 4)]:
match divide_result(a, b):
case Ok(value):
print(f"{a} // {b} = {value}")
case Err(e):
print(f"Error: {e}")
# output: 10 // 2 = 5
# output: Error: Cannot divide by zero
# output: 15 // 4 = 3
This approach gives you exhaustive handling — if you forget a case, Python warns you.
Transforming Values
The map method transforms the success value without touching errors:
from result import Ok, Err
Ok(5).map(lambda x: x * 2) # Ok(10)
Err("error").map(lambda x: x * 2) # Err('error')
# Use map_err to transform the error
Ok(5).map_err(str.upper) # Ok(5)
Err("error").map_err(str.upper) # Err('ERROR')
Chain transformations:
result = (
Ok("10")
.map(int) # Ok(10)
.map(lambda x: x + 1) # Ok(11)
)
# output: Ok(11)
Safe Unwrapping
Never use .unwrap() in production — it raises an exception on failure. Use .unwrap_or() for static defaults:
from result import Ok, Err
Ok(10).unwrap_or(0) # 10
Err("oops").unwrap_or(0) # 0
# Lambda fallback for dynamic defaults — use unwrap_or_else!
Err("oops").unwrap_or_else(lambda: 42) # 42
# Calculate default only when needed
def calculate_default():
print("Computing default...")
return 100
Err("error").unwrap_or_else(calculate_default)
# output: Computing default...
# output: 100
Chaining Operations
Use and_then to chain operations that can fail. It short-circuits on the first error:
from result import Ok, Err, Result
def parse_int(s: str) -> Result[int, ValueError]:
try:
return Ok(int(s))
except ValueError as e:
return Err(e)
def divide_result(a: int, b: int) -> Result[int, str]:
if b == 0:
return Err("Division by zero")
return Ok(a // b)
def process(a: str, b: str) -> Result[int, str]:
return (
parse_int(a)
.and_then(lambda _: parse_int(b))
.and_then(lambda _: divide_result(int(a), int(b)))
)
result = process("10", "2")
# output: Ok(5)
result = process("10", "0")
# output: Err('Division by zero')
result = process("ten", "2")
# output: Err(ValueError('invalid literal for int() with base 10: 'ten''))
A cleaner approach using explicit parameters:
def process(a: str, b: str) -> Result[int, str]:
return (
parse_int(a)
.and_then(lambda a_val: parse_int(b))
.and_then(lambda b_val: divide_result(a_val, b_val))
)
Converting Exceptions to Results
The @as_result decorator wraps risky code and catches specified exceptions:
from result import Ok, Err, as_result
@as_result(ValueError, KeyError)
def get_item(data: dict, key: str) -> str:
return data[key]
result = get_item({"name": "Alice"}, "name")
# output: Ok('Alice')
result = get_item({}, "missing")
# output: Err(KeyError('missing'))
Combine with pattern matching:
data = {"users": [{"id": 1, "name": "Alice"}]}
match get_item(data, "users"):
case Ok(users):
print(f"Found {len(users)} users")
case Err(e):
print(f"Key missing: {e}")
Type Narrowing for Mypy
The is_ok() method doesn’t narrow types for mypy. Use isinstance for proper type hints:
from result import Ok, Err, Result
def handle(result: Result[int, str]):
# This doesn't work for mypy type narrowing
if is_ok(result):
reveal_type(result) # Result[int, str] — not narrowed
# This works
if isinstance(result, Ok):
reveal_type(result) # Ok[int, str] — properly narrowed
print(result.ok_value)
else:
reveal_type(result) # Err[str] — properly narrowed
print(result.err_value)
Real-World Example: User Validation
Here’s a practical example for validating user input:
from result import Ok, Err, Result
class ValidationError:
def __init__(self, field: str, message: str):
self.field = field
self.message = message
def __str__(self):
return f"{self.field}: {self.message}"
def validate_email(email: str) -> Result[str, ValidationError]:
if "@" not in email:
return Err(ValidationError("email", "Missing @ symbol"))
if "." not in email.split("@")[1]:
return Err(ValidationError("email", "Missing domain"))
return Ok(email)
def validate_age(age: int) -> Result[int, ValidationError]:
if age < 0:
return Err(ValidationError("age", "Must be positive"))
if age > 150:
return Err(ValidationError("age", "Too old"))
return Ok(age)
def validate_user(email: str, age: int) -> Result[dict, ValidationError]:
return (
validate_email(email)
.and_then(lambda _: validate_age(age))
.map(lambda _: {"email": email, "age": age})
)
# Test cases
print(validate_user("alice@example.com", 25))
# output: Ok({'email': 'alice@example.com', 'age': 25})
print(validate_user("invalid", 25))
# output: Err(email: Missing @ symbol)
print(validate_user("alice@example.com", -5))
# output: Err(age: Must be positive)
When to Use Result Types
Use Result types when:
- Errors are expected outcomes, not bugs (validation, parsing, network failures)
- You want explicit error handling without try/except clutter
- You’re building pure functions where errors should be part of the return type
Keep using exceptions for truly exceptional cases — out of memory, file system corruption, programming errors that indicate bugs.
See Also
- Exception Handling — Traditional exception handling with try/except
- raise keyword — How to raise exceptions in Python
- typing module — Type hints and generics including Result types