Result Types and Error Handling Without Exceptions

· 5 min read · Updated March 21, 2026 · intermediate
python error-handling functional-programming result-types

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