Skip to content

Result

fptk.adt.result provides the Result type for handling operations that can succeed or fail. Instead of throwing exceptions, Result makes errors explicit and composable.

Concept: The Either/Result Monad

In functional programming, Result (also called Either in Haskell) represents a computation that can succeed with a value or fail with an error. It has two cases:

  • Ok(value): The computation succeeded
  • Err(error): The computation failed

This matters because:

  • Explicit error handling: The type signature tells you something can fail
  • Composable error paths: Chain operations and handle all errors at the end
  • No hidden control flow: No exceptions jumping through your call stack
  • Railway-oriented programming: Success and error paths run in parallel tracks

The Problem with Exceptions

def process(data):
    parsed = json.loads(data)        # Might raise JSONDecodeError
    validated = validate(parsed)      # Might raise ValidationError
    result = transform(validated)     # Might raise TransformError
    return result

# Caller has no idea what might be thrown
try:
    result = process(data)
except json.JSONDecodeError as e:
    # Handle parse error
except ValidationError as e:
    # Handle validation error
except TransformError as e:
    # Handle transform error

The Result Solution

from fptk.adt.result import Ok, Err
from fptk.core.func import pipe

def process(data: str) -> Result[Output, str]:
    return pipe(
        data,
        parse_json,        # Returns Result[dict, str]
        lambda r: r.bind(validate),     # Result[Validated, str]
        lambda r: r.bind(transform),    # Result[Output, str]
    )

# Caller sees the Result type and handles it
result = process(data)
result.match(
    ok=lambda output: save(output),
    err=lambda error: log_error(error)
)

The error type is visible. Each step's error becomes part of the chain. One handling point at the end.

API

Types

Type Description
Result[T, E] Base type: success T or error E
Ok[T, E] Success variant containing value of type T
Err[T, E] Failure variant containing error of type E

Constructors

from fptk.adt.result import Ok, Err

success = Ok(42)
failure = Err("something went wrong")

Methods

Method Signature Description
is_ok() () -> bool Returns True if Ok
is_err() () -> bool Returns True if Err
map(f) (T -> U) -> Result[U, E] Transform success value
bind(f) (T -> Result[U, E]) -> Result[U, E] Chain Result-returning functions
and_then(f) (T -> Result[U, E]) -> Result[U, E] Alias for bind (Rust naming)
flatten() Result[Result[T, E], E] -> Result[T, E] Unwrap nested Result
zip(other) (Result[U, E]) -> Result[tuple[T, U], E] Combine two Results into tuple
zip_with(other, f) (Result[U, E], (T, U) -> R) -> Result[R, E] Combine two Results with function
ap(other) Result[T -> U, E].ap(Result[T, E]) -> Result[U, E] Apply wrapped function to wrapped value
map_err(f) (E -> F) -> Result[T, F] Transform error value
bimap(ok, err) (T -> U, E -> F) -> Result[U, F] Transform both sides at once
recover(f) (E -> T) -> Result[T, E] Convert Err to Ok using function
recover_with(f) (E -> Result[T, E]) -> Result[T, E] Convert Err to another Result
unwrap_or(default) (U) -> T | U Get value or default
unwrap_or_else(f) (E -> U) -> T | U Get value or compute from error
match(ok, err) (T -> U, E -> U) -> U Pattern match both cases
unwrap() () -> T Get value or raise ValueError
expect(msg) (str) -> T Get value or raise with message

Async Methods

Method Signature Description
map_async(f) async (T -> U) -> Result[U, E] Async transform success
bind_async(f) async (T -> Result[U, E]) -> Result[U, E] Async chain

How It Works

Data Structure

Result is implemented as a sealed type with two variants:

class Result[T, E]:
    """Base class - not instantiated directly."""
    pass

@dataclass(frozen=True, slots=True)
class Ok[T, E](Result[T, E]):
    value: T

@dataclass(frozen=True, slots=True)
class Err[T, E](Result[T, E]):
    error: E

The Functor: map

map transforms the success value, leaving errors unchanged:

def map(self, f):
    if isinstance(self, Ok):
        return Ok(f(self.value))
    return self  # Err passes through

The Monad: bind

bind chains operations that return Result:

def bind(self, f):
    if isinstance(self, Ok):
        return f(self.value)  # f returns Result[U, E]
    return self  # Err passes through

The Bifunctor: map_err

Unlike Option, Result can transform its error too:

def map_err(self, f):
    if isinstance(self, Err):
        return Err(f(self.error))
    return self  # Ok passes through

Transform Both Sides: bimap

When you need to transform both the success and error values, use bimap for efficiency:

result.bimap(
    ok=lambda x: x * 2,        # Transform success
    err=lambda e: f"Error: {e}" # Transform error
)

# Equivalent to (but more efficient than):
result.map(lambda x: x * 2).map_err(lambda e: f"Error: {e}")

Example: converting internal types to API response types:

def to_api_response(result: Result[User, DbError]) -> Result[UserDTO, ApiError]:
    return result.bimap(
        ok=lambda user: UserDTO.from_user(user),
        err=lambda e: ApiError(code=500, message=str(e))
    )

Railway-Oriented Programming

Think of Result as a railway track with two rails:

     Ok path  ─────┬─────┬─────┬─────> Success
                   │     │     │
     Err path ─────┴─────┴─────┴─────> Failure
               parse  validate transform

Each function either continues on the Ok track or switches to the Err track. Once on the Err track, you stay there (errors propagate automatically).

Examples

Wrapping Exceptions

from fptk.core.func import try_catch
from fptk.adt.result import Ok, Err

# Automatic wrapping
safe_parse = try_catch(json.loads)
safe_parse('{"a": 1}')  # Ok({"a": 1})
safe_parse('invalid')    # Err(JSONDecodeError(...))

# Manual wrapping
def parse_int(s: str) -> Result[int, str]:
    try:
        return Ok(int(s))
    except ValueError:
        return Err(f"'{s}' is not a valid integer")

Chaining Operations

def validate_age(data: dict) -> Result[dict, str]:
    age = data.get("age")
    if age is None:
        return Err("age is required")
    if not isinstance(age, int):
        return Err("age must be an integer")
    if age < 0 or age > 150:
        return Err("age must be between 0 and 150")
    return Ok(data)

def validate_email(data: dict) -> Result[dict, str]:
    email = data.get("email")
    if not email or "@" not in email:
        return Err("valid email is required")
    return Ok(data)

def process_user(raw: str) -> Result[User, str]:
    return (
        try_catch(json.loads)(raw)
        .map_err(lambda e: f"Invalid JSON: {e}")
        .bind(validate_age)
        .bind(validate_email)
        .map(lambda d: User(**d))
    )

Error Transformation

# Convert detailed errors to user-friendly messages
def user_friendly_error(e: Exception) -> str:
    if isinstance(e, json.JSONDecodeError):
        return "The data format is invalid"
    if isinstance(e, ValidationError):
        return f"Please check your input: {e.field}"
    return "An unexpected error occurred"

result = (
    process_data(raw)
    .map_err(user_friendly_error)
)

Pattern Matching

def respond(result: Result[User, str]) -> Response:
    return result.match(
        ok=lambda user: Response(200, {"user": user.to_dict()}),
        err=lambda error: Response(400, {"error": error})
    )

Fallback Values

# Simple default
value = parse_int(input).unwrap_or(0)

# Computed default (only runs on error)
value = parse_int(input).unwrap_or_else(
    lambda err: log_and_return_default(err)
)

Recovering from Errors

Use recover to convert an Err to Ok with a fallback value:

from fptk.adt.result import Ok, Err

# Provide a default value on error
Err("not found").recover(lambda e: "default")  # Ok("default")
Ok(5).recover(lambda e: 0)  # Ok(5) - unchanged

# Practical example: config with fallback
def get_config(key: str) -> Result[str, str]:
    return read_config_file(key).recover(lambda e: default_config[key])

Use recover_with for conditional recovery where some errors can be handled:

from fptk.adt.result import Ok, Err

def fetch_with_retry(url: str) -> Result[Response, str]:
    return fetch(url).recover_with(lambda e:
        fetch(url) if e == "timeout" else Err(e)  # Retry only timeouts
    )

# Chain multiple recovery strategies
result = (
    fetch_from_primary()
    .recover_with(lambda e: fetch_from_secondary())  # Try backup
    .recover(lambda e: cached_response)              # Last resort: cache
)

Combining with Option

from fptk.adt.option import from_nullable

def get_user_email(user_id: int) -> Result[str, str]:
    return (
        from_nullable(db.get(user_id))
        .to_result(f"User {user_id} not found")
        .bind(lambda user:
            from_nullable(user.get("email"))
            .to_result("User has no email")
        )
    )

Flattening Nested Results

Use flatten when you have a Result[Result[T, E], E] and want Result[T, E]:

from fptk.adt.result import Ok, Err

# Direct usage
Ok(Ok(42)).flatten()       # Ok(42)
Ok(Err("inner")).flatten() # Err("inner")
Err("outer").flatten()     # Err("outer")

# Common scenario: map with a function that returns Result
def fetch_user(id: int) -> Result[User, str]: ...
def fetch_permissions(user: User) -> Result[Permissions, str]: ...

# Without flatten: Result[Result[Permissions, str], str]
nested = fetch_user(1).map(fetch_permissions)

# With flatten: Result[Permissions, str]
permissions = fetch_user(1).map(fetch_permissions).flatten()

# Note: this is equivalent to using bind directly
permissions = fetch_user(1).bind(fetch_permissions)

Applicative Apply

Use ap to apply a wrapped function to a wrapped value:

from fptk.adt.result import Ok, Err

# Basic usage
Ok(lambda x: x + 1).ap(Ok(5))        # Ok(6)
Ok(lambda x: x + 1).ap(Err("oops"))  # Err("oops")
Err("no func").ap(Ok(5))             # Err("no func")

# Curried functions for multiple arguments
def add(a: int):
    return lambda b: a + b

Ok(add).ap(Ok(1)).ap(Ok(2))  # Ok(3)

# Error at any step propagates (first error wins)
Ok(add).ap(Err("e1")).ap(Ok(2))   # Err("e1")
Ok(add).ap(Ok(1)).ap(Err("e2"))   # Err("e2")

# Practical example: combining validated inputs
def create_user(name: str):
    return lambda email: {"name": name, "email": email}

user = Ok(create_user).ap(validate_name(name)).ap(validate_email(email))
# Ok({...}) if both valid, else first Err

When to Use Result

Use Result when:

  • An operation can fail and you want to handle the error
  • You want typed errors instead of string exceptions
  • You're building a pipeline where errors should propagate
  • You want to force callers to acknowledge potential failures

Don't use Result when:

  • Failure is truly exceptional (programming bugs, out of memory)
  • You're in a hot loop and performance matters
  • The error doesn't carry useful information → consider Option

Comparison with Option

Aspect Option Result
Cases Some(T), Nothing Ok(T), Err(E)
Absence info No Yes (error type)
Use case Value might not exist Operation might fail
Convert to .to_result(err) N/A

See Also