better-py

Result Monad

Explicit error handling with Ok and Error variants

The Result monad represents operations that can succeed (Ok) or fail (Error). It's a type-safe alternative to exceptions for error handling.

Result[T, E] has two variants:

  • Ok(value) - Contains a success value of type T
  • Error(error) - Contains an error of type E

Creating & Checking

Create Result values using Ok() and Error() constructors. Use is_ok() and is_error() to check which variant you have.

from better_py import Ok, Error

# Create values
success = Ok(42)
failure = Error("Something went wrong")

# Check variants
success.is_ok()      # True
success.is_error()   # False
failure.is_ok()      # False
failure.is_error()   # True

You can also use the Result class methods for creating values:

from better_py import Result

Result.ok(42)                    # Ok(42)
Result.error("Something failed")  # Error("Something failed")
Result.from_value(42)            # Ok(42)

Extracting Values

The unwrap method returns the success value for Ok or raises an error for Error. Use unwrap_or and unwrap_or_else to provide defaults. Use unwrap_error to extract the error value.

from better_py import Ok, Error

Ok(42).unwrap()                    # 42
Error("failed").unwrap()           # Raises ValueError

Ok(42).unwrap_or(0)                # 42
Error("failed").unwrap_or(0)       # 0

Ok(42).unwrap_or_else(lambda: expensive_computation())  # 42
Error("failed").unwrap_or_else(lambda: expensive_computation())  # Computation called

Error("failed").unwrap_error()     # "failed"
Ok(42).unwrap_error()              # Raises ValueError

Use unwrap when you're certain the result is successful. Use unwrap_or for simple defaults. Use unwrap_or_else for expensive default computation. Use unwrap_error to access error information.

Transforming Values

The map method applies a function only to Ok, leaving Error unchanged. The map_error method applies a function only to Error, leaving Ok unchanged.

from better_py import Ok, Error

Ok(5).map(lambda x: x * 2)           # Ok(10)
Error("bad").map(lambda x: x * 2)    # Error("bad")

Error("bad").map_error(str.upper)    # Error("BAD")
Ok(42).map_error(str.upper)          # Ok(42)

When transforming, the opposite variant passes through unchanged. This short-circuiting behavior is key to Result's utility in error handling.

Chaining Operations

The flat_map method chains operations that return Result. If any step returns Error, the rest of the chain is skipped—errors propagate automatically.

from better_py import Ok, Error

def validate_positive(x: int):
    return Ok(x) if x > 0 else Error("Must be positive")

def validate_non_zero(x: int):
    return Ok(x) if x != 0 else Error("Must be non-zero")

def divide(a: int, b: int):
    return (validate_positive(a)
        .flat_map(lambda _: validate_non_zero(b))
        .map(lambda _: a / b))

divide(10, 2)   # Ok(5.0)
divide(10, 0)   # Error("Must be non-zero")
divide(-5, 2)   # Error("Must be positive")

This replaces nested if/else chains with a flat, readable pipeline. Each step receives the previous Ok value, or short-circuits on the first Error.

The methods bind and and_then are aliases for flat_map—use whichever reads best in your code.

Combining Results

The zip method combines multiple Result values into a tuple. If any value is Error, zip returns that Error immediately.

from better_py import Result, Ok, Error

Result.zip(Ok(1), Ok(2), Ok(3))           # Ok((1, 2, 3))
Result.zip(Ok(1), Error("bad"), Ok(3))    # Error("bad")

Use zip when you need all values to proceed—like validating multiple fields where any failure should reject the entire operation.

The ap method applies a function wrapped in a Result to a value wrapped in a Result. Both must be Ok for the operation to succeed.

from better_py import Ok

add_one = Ok(lambda x: x + 1)
value = Ok(5)

add_one.ap(value)      # Ok(6)

Error("bad").ap(value)    # Error("bad")
add_one.ap(Error("bad"))  # Error("bad")

The lift2 and lift3 methods transform regular functions into functions that work with Result values.

from better_py import Result, Ok

def add(x, y):
    return x + y

Result.lift2(add, Ok(5), Ok(10))    # Ok(15)
Result.lift2(add, Ok(5), Error("bad"))  # Error("bad")

Use these when you have existing functions that don't know about Result, and you want to use them with Result values without rewriting the functions.

Fallback Values

The or_else method returns this Result if it's Ok, or another Result if this one is Error. This is useful for chaining fallback operations.

from better_py import Ok, Error

Ok(5).or_else(Ok(10))         # Ok(5)
Error("bad").or_else(Ok(10))  # Ok(10)

# Chain fallbacks
result = (get_from_cache(key)
    .or_else(get_from_database(key))
    .or_else(get_from_api(key)))

Use or_else when you want to try multiple sources in order, falling back to the next if the previous returns Error.

Pattern Matching

The fold method handles both cases and returns a non-Result result. You provide two functions—one for each case—and fold applies the appropriate one.

from better_py import Ok, Error

def describe(result):
    return result.fold(
        on_ok=lambda value: f"Success: {value}",
        on_error=lambda err: f"Error: {err}"
    )

describe(Ok(42))           # "Success: 42"
describe(Error("failed"))  # "Error: failed"

fold is the ultimate pattern match for Result—it's the only operation that lets you handle both cases in one expression and return a non-Result result.

Real-World Pattern: Validation Chain

from better_py import Ok, Error

def validate_age(age: int):
    if age < 0:
        return Error("Age cannot be negative")
    if age > 150:
        return Error("Age is unrealistic")
    return Ok(age)

def validate_email(email: str):
    if "@" not in email:
        return Error("Invalid email format")
    return Ok(email)

def create_user(age: int, email: str):
    return (validate_age(age)
        .flat_map(lambda _: validate_email(email))
        .map(lambda _: {"age": age, "email": email}))

create_user(25, "user@example.com")  # Ok({"age": 25, "email": "user@example.com"})
create_user(-5, "user@example.com")  # Error("Age cannot be negative")
create_user(25, "invalid")            # Error("Invalid email format")

This pattern shows Result's strength: each validation step can return Error with a message, and the entire pipeline short-circuits on the first failure. No intermediate variables or nested conditionals needed.

Real-World Pattern: API Error Handling

from better_py import Ok, Error

import requests

class APIError:
    def __init__(self, message: str, status_code: int):
        self.message = message
        self.status_code = status_code

def fetch_url(url: str):
    """Fetch URL and return Result[str, APIError]."""
    try:
        response = requests.get(url, timeout=5)
        if response.status_code == 200:
            return Ok(response.text)
        return Error(APIError(f"HTTP {response.status_code}", response.status_code))
    except requests.RequestException as e:
        return Error(APIError(str(e), 0))

def parse_json(text: str):
    """Parse JSON and return Result[dict, APIError]."""
    try:
        return Ok(json.loads(text))
    except json.JSONDecodeError as e:
        return Error(APIError(f"Invalid JSON: {e}", 0))

def get_user_id(url: str):
    """Fetch URL, parse JSON, extract user_id."""
    return (fetch_url(url)
        .flat_map(parse_json)
        .map(lambda data: data.get("user_id")))

get_user_id("https://api.example.com/user/1")
# Ok(123) or Error(APIError(...))

This pattern shows Result's use in API operations: each step can fail with a descriptive error, and errors automatically propagate through the chain. The caller gets either the final value or the first error that occurred.

When to Use

Use Result when:

  • Operations can fail with meaningful errors
  • You want explicit error handling
  • You need error messages/context
  • You want to avoid exceptions

Don't use Result when:

  • A value might be missing (use Maybe instead)
  • You need to accumulate errors (use Validation instead)
  • The operation cannot fail (use plain types)

Comparison with Maybe and Either

All three monads handle operations that might not produce a value, but they serve different purposes:

FeatureResultMaybeEither
PurposeError handlingOptional valuesTwo distinct possibilities
Generic typesResult[T, E] (success/error)Maybe[T] (value/none)Either[L, R] (any two types)
VariantsOk(value), Error(error)Some(value), NothingLeft(value), Right(value)
Error context✅ Error type only❌ None✅ Any type
Use caseOperations that failValues that might be missingTwo-value alternatives

Examples:

# Result: Success or error with context
Result[User, str]  # Ok(User) or Error("User not found")

# Maybe: Value exists or not
Maybe[User]  # Some(User) or Nothing

# Either: Two possibilities with rich context
Either[APIError, User]  # Left(APIError) or Right(User)

Rule of thumb:

  • Use Result when you have explicit success/error cases
  • Use Maybe when a value might simply not exist (no error involved)
  • Use Either when Left/right are two distinct possibilities (beyond success/error)

See Also

On this page