Typesafe railway-oriented programming (ROP)
Project description
trcks 🚂
trcks is a Python library.
It allows
railway-oriented programming
in two different programming styles:
- an object-oriented style based on method chaining and
- a functional style based on function composition.
Motivation
The following subsections motivate
railway-oriented programming in general and
the trcks library in particular.
Why should I use railway-oriented programming?
When writing modular Python code,
return type annotations are extremely helpful.
They help humans
(and maybe LLMs)
to understand the purpose of a function.
And they allow static type checkers (e.g. mypy or pyright)
to check whether functions fit together:
>>> def get_user_id(user_email: str) -> int:
... if user_email == "erika.mustermann@domain.org":
... return 1
... if user_email == "john_doe@provider.com":
... return 2
... raise Exception("User does not exist")
...
>>> def get_subscription_id(user_id: int) -> int:
... if user_id == 1:
... return 42
... raise Exception("User does not have a subscription")
...
>>> def get_subscription_fee(subscription_id: int) -> float:
... return subscription_id * 0.1
...
>>> def get_subscription_fee_by_email(user_email: str) -> float:
... return get_subscription_fee(get_subscription_id(get_user_id(user_email)))
...
>>> get_subscription_fee_by_email("erika.mustermann@domain.org")
4.2
Unfortunately, conventional return type annotations do not always tell the full story:
>>> get_subscription_id(user_id=2)
Traceback (most recent call last):
...
Exception: User does not have a subscription
We can document domain exceptions in the docstring of the function:
>>> def get_subscription_id(user_id: int) -> int:
... """Look up the subscription ID for a user.
...
... Raises:
... Exception: If the user does not have a subscription.
... """
... if user_id == 1:
... return 42
... raise Exception("User does not have a subscription")
...
While this helps humans (and maybe LLMs), static type checkers usually ignore docstrings. Moreover, it is difficult to document all domain exceptions in the docstring and to keep this documentation up-to-date. Therefore, we should use railway-oriented programming.
How can I use railway-oriented programming?
Instead of raising exceptions (and documenting this behavior in the docstring),
we return a Result type:
>>> from typing import Literal
>>> from trcks import Result
>>>
>>> UserDoesNotHaveASubscription = Literal["User does not have a subscription"]
>>>
>>> def get_subscription_id(
... user_id: int
... ) -> Result[UserDoesNotHaveASubscription, int]:
... if user_id == 1:
... return "success", 42
... return "failure", "User does not have a subscription"
...
>>> get_subscription_id(user_id=1)
('success', 42)
>>> get_subscription_id(user_id=2)
('failure', 'User does not have a subscription')
This return type
- describes the success case and the failure case and
- is verified by static type checkers.
What do I need for railway-oriented programming?
Combining Result-returning functions
with other Result-returning functions or with "regular" functions
can be cumbersome.
Moreover, it can lead to repetitive code patterns:
>>> from typing import Union
>>>
>>> UserDoesNotExist = Literal["User does not exist"]
>>> FailureDescription = Union[UserDoesNotExist, UserDoesNotHaveASubscription]
>>>
>>> def get_user_id(user_email: str) -> Result[UserDoesNotExist, int]:
... if user_email == "erika.mustermann@domain.org":
... return "success", 1
... if user_email == "john_doe@provider.com":
... return "success", 2
... return "failure", "User does not exist"
...
>>> def get_subscription_fee_by_email(
... user_email: str
... ) -> Result[FailureDescription, float]:
... # Apply get_user_id:
... user_id_result = get_user_id(user_email)
... if user_id_result[0] == "failure":
... return user_id_result
... user_id = user_id_result[1]
... # Apply get_subscription_id:
... subscription_id_result = get_subscription_id(user_id)
... if subscription_id_result[0] == "failure":
... return subscription_id_result
... subscription_id = subscription_id_result[1]
... # Apply get_subscription_fee:
... subscription_fee = get_subscription_fee(subscription_id)
... # Return result:
... return "success", subscription_fee
...
>>> get_subscription_fee_by_email("erika.mustermann@domain.org")
('success', 4.2)
>>> get_subscription_fee_by_email("john_doe@provider.com")
('failure', 'User does not have a subscription')
>>> get_subscription_fee_by_email("jane_doe@provider.com")
('failure', 'User does not exist')
Therefore, we need a library that helps us combine functions.
How does the module trcks.oop help with function combination?
The module trcks.oop supports combining functions in an object-oriented style
using method chaining:
>>> from trcks.oop import Wrapper
>>>
>>> def get_subscription_fee_by_email(
... user_email: str
... ) -> Result[FailureDescription, float]:
... return (
... Wrapper(core=user_email)
... .map_to_result(get_user_id)
... .map_success_to_result(get_subscription_id)
... .map_success(get_subscription_fee)
... .core
... )
...
>>> get_subscription_fee_by_email("erika.mustermann@domain.org")
('success', 4.2)
>>> get_subscription_fee_by_email("john_doe@provider.com")
('failure', 'User does not have a subscription')
>>> get_subscription_fee_by_email("jane_doe@provider.com")
('failure', 'User does not exist')
How does the package trcks.fp help with function combination?
The package trcks.fp supports combining functions in a functional style
using function composition:
>>> from trcks.fp.composition import Pipeline3, pipe
>>> from trcks.fp.monads import result as r
>>>
>>> def get_subscription_fee_by_email(
... user_email: str
... ) -> Result[FailureDescription, float]:
... # If your static type checker cannot infer
... # the type of the argument passed to `pipe`,
... # explicit type assignment can help:
... pipeline: Pipeline3[
... str,
... Result[UserDoesNotExist, int],
... Result[FailureDescription, int],
... Result[FailureDescription, float],
... ] = (
... user_email,
... get_user_id,
... r.map_success_to_result(get_subscription_id),
... r.map_success(get_subscription_fee),
... )
... return pipe(pipeline)
...
>>> get_subscription_fee_by_email("erika.mustermann@domain.org")
('success', 4.2)
>>> get_subscription_fee_by_email("john_doe@provider.com")
('failure', 'User does not have a subscription')
>>> get_subscription_fee_by_email("jane_doe@provider.com")
('failure', 'User does not exist')
Setup
trcks is available on PyPI.
Use your favorite package manager (e.g. pip, poetry or uv) to install it.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file trcks-0.3.3.tar.gz.
File metadata
- Download URL: trcks-0.3.3.tar.gz
- Upload date:
- Size: 24.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.8.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7b453e60cf0ea735d9025edf321814d3e33e878b1a0b4b67311ddb1946d39573
|
|
| MD5 |
b0e94acc2bc4b9d43cfde4f200968ac0
|
|
| BLAKE2b-256 |
064769a11a0a686d9f4c32489df646aa24da3118b70909a4147b59aba2fe91b6
|
File details
Details for the file trcks-0.3.3-py3-none-any.whl.
File metadata
- Download URL: trcks-0.3.3-py3-none-any.whl
- Upload date:
- Size: 25.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.8.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
166f33738824a49c6fe6c0e7279b429721f78ca78b9fc49049f375ac0ae4ca60
|
|
| MD5 |
43d63178fd92328c06d733f660035b45
|
|
| BLAKE2b-256 |
4031c981973e2916993cdf79064c496d02af79c817105c274c8c8f99e96dae4c
|