Skip to content

Functional Python: Better Error Handling with Railway Oriented Programming and the expression Library

By João Monteiro5 min read

Introduction

Python, known for its flexibility and readability, also embraces functional programming paradigms. While not a purely functional language like Haskell, Python supports concepts like higher-order functions, lambdas, and immutable data structures. These features allow us to write cleaner, more predictable, and easier-to-reason-about code.

One powerful functional programming pattern making its way into the Python world is Railway Oriented Programming (ROP). This approach, popularized by Scott Wlaschin in the F# community, provides an elegant way to handle errors and compose functions in a robust and predictable manner. In this post, we'll explore ROP and see how the expression library helps us implement it in Python using its Result and Option types.

What is Railway Oriented Programming?

Imagine building a railway track. You have two tracks: a "success" track and a "failure" track. Data flows along the "success" track until an error occurs. When an error happens, the data switches to the "failure" track and bypasses the remaining "success-only" operations.

In ROP, functions are designed to handle both success and failure cases. They typically return a special type that can represent either a successful result or an error. This is where the Result type comes in.

The Result Type: Explicit Error Handling

The expression library provides a Result type that is similar to Either in other languages. A Result can either be Ok(value) representing a successful operation with a value or Error(error) representing a failed operation with an error value.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
from expression import Ok, Error, Result

def divide(x: int, y: int) -> Result[int, str]:
  """Divides x by y, returning Ok(result) or Error(message)."""
  if y == 0:
    return Error("Division by zero")
  else:
    return Ok(x // y)

# Example usage
result1 = divide(10, 2)  # Ok(5)
result2 = divide(10, 0)  # Error("Division by zero")

match result1:
    case Ok(value):
        print(f"Success: {value}")
    case Error(err):
        print(f"Error: {err}")

Instead of using exceptions, which can disrupt the normal flow of the program, Result makes error handling explicit. We can chain functions that return Result values, and the entire pipeline will short-circuit if any function returns an Error.

The Option Type: Handling Optional Values

Similar to Result, the Option type helps us deal with values that might be present or absent. It has two states: Some(value) when a value is present, and Nothing when a value is absent.

My Cool Header
from expression import Some, Nothing, Option

def find_user(username: str) -> Option[str]:
  """Finds a user by username, returning Some(user) or Nothing."""
  users = {"alice": "Alice Smith", "bob": "Bob Johnson"}
  if username in users:
    return Some(users[username])
  else:
    return Nothing

# Example usage
user = find_user("alice")  # Some("Alice Smith")
no_user = find_user("eve")  # Nothing

match user:
    case Some(value):
        print(f"User found: {value}")
    case Nothing:
        print("User not found")

Railway Oriented Programming with expression

The expression library makes it easy to chain operations using the pipe function (similar to the pipe operator |> in F#) or by chaining methods directly on Result and Option objects.

from expression import pipe, Result, Ok, Error
from expression.core import option

def parse_int(s: str) -> Result[int, str]:
  """Parses a string to an integer, returning Ok(int) or Error(message)."""
  try:
    return Ok(int(s))
  except ValueError:
    return Error(f"Invalid integer: {s}")

def add_one(x: int) -> Result[int, str]:
    return Ok(x + 1)

def divide_by_two(x: int) -> Result[int, str]:
    return Ok(x / 2)

# Chaining operations using pipe
result = pipe(
  "10",
  parse_int,
  Result.bind(add_one),  # Bind applies a function to the Ok value, or propagates Error
  Result.bind(divide_by_two)
)
print(result)

#Error handling
result = pipe(
  "abc",
  parse_int,
  Result.bind(add_one),  # Bind applies a function to the Ok value, or propagates Error
  Result.bind(divide_by_two)
)

print(result)

Benefits of ROP

Effects for Easier Composition

The expression library provides "effects" that simplify composing functions returning Option or Result values.

from expression import effect, Ok, Error, Result

@effect.result[int, str]()
def process_data(data: str) -> Result[int, str]:
    parsed_value = yield from parse_int(data)  # Result[int, str]
    incremented_value = yield from add_one(parsed_value)  # Result[int, str]
    result = yield from divide_by_two(incremented_value)  # Result[int, str]
    return result

# The effect handles the chaining and error propagation
result = process_data("20")  # Ok(10.5)
error_result = process_data("abc")  # Error("Invalid integer: abc")

Conclusion

Railway Oriented Programming, combined with the Result and Option types from the expression library, offers a powerful way to write robust and maintainable functional code in Python. By making error handling explicit and using functional composition, we can create code that is easier to understand, test, and reason about. As Python continues to evolve, embracing functional paradigms like ROP can lead to more elegant and resilient software.

Further Exploration