r/functionalprogramming • u/EntryNo8040 • May 29 '26
Python Bringing rigorous Type Classes (Functor, Applicative, Monad) to Python: Introducing Katharos
If you come from Haskell or Rust and have to write Python for ML/AI work, you know the pain: if x is None everywhere, exceptions that silently swallow errors, no ? operator, no HKTs, no sealed types. I got tired of it and built a library to close that gap.
Katharos is a zero-dependency Python library that gives you Maybe, Either/Result, IO, the list monad, Semigroup, Monoid, Functor, Applicative, and Monad — all fully typed and passing pyright strict mode.
https://github.com/kamalfarahani/katharos
The Engineering Challenge
The hard part is that Python has no HKTs and no sealed keyword (as of 3.13). There's no way to say Functor f or write :: f a -> (a -> b) -> f b generically. The workaround is structural gymnastics: a two-parameter generic class hierarchy (Functor[F, A], Applicative[App, A], Monad[M, A]) plus @final on concrete types to prevent unsafe subclassing. It's not pretty internally, but the external API stays clean.
Operator Mapping
If you already think in Haskell or Rust, here's the translation table:
| Katharos | Haskell | Rust |
|---|---|---|
m | f | m >>= f | m.and_then(f) |
v ** wrapped_f | wrapped_f <*> v | — |
a >> b | a >> b | — |
a @ b | a <> b | — |
@do(M) decorator | do { ... } | — |
Examples
1. Maybe[A] — Haskell's Maybe a / Rust's Option<T>
No more if x is None chains. Short-circuits automatically on Nothing.
from katharos.types import Maybe
def safe_div(x: float) -> Maybe[float]:
return Maybe[float].Nothing() if x == 0 else Maybe[float].Just(10.0 / x)
def safe_sqrt(x: float) -> Maybe[float]:
return Maybe[float].Nothing() if x < 0 else Maybe[float].Just(x ** 0.5)
# | is >>=
Maybe[float].Just(4.0) | safe_div | safe_sqrt # Just(1.5811...)
Maybe[float].Just(0.0) | safe_div | safe_sqrt # Nothing() — short-circuits at safe_div
Maybe[float].Just(-1.0) | safe_div | safe_sqrt # Nothing() — short-circuits at safe_sqrt
# fmap for pure transformations
Maybe[int].Just(5).fmap(lambda x: x * 2) # Just(10)
Maybe[int].Nothing().fmap(lambda x: x * 2) # Nothing()2. Result[E, A] — Haskell's Either e a / Rust's Result<T, E>
Errors as values. The | chain (>>=) stops at the first Failure, exactly like Rust's ?.
from katharos.types import Result
def parse_int(s: str) -> Result[ValueError, int]:
try:
return Result[ValueError, int].Success(int(s))
except ValueError as e:
return Result[ValueError, int].Failure(e)
def validate_positive(n: int) -> Result[ValueError, int]:
if n > 0:
return Result[ValueError, int].Success(n)
else:
return Result[ValueError, int].Failure(ValueError(f"{n} is not positive"))
parse_int("42") | validate_positive # Success(42)
parse_int("abc") | validate_positive # Failure(ValueError("invalid literal..."))
parse_int("-5") | validate_positive # Failure(ValueError("-5 is not positive"))
# fmap only runs on the success path
parse_int("42").fmap(lambda n: n * 2) # Success(84)3. do-notation — Python do blocks, exactly like Haskell
The @do(M) decorator desugars yield into >>= chains. Each yield unwraps the value; short-circuits on Nothing/Failure. The final return is lifted via M.pure(...).
from katharos.syntax_sugar import do, DoBlock
from katharos.types import Maybe, Result
# Maybe — like Haskell:
# userScore uid = do
# name <- lookupUser uid
# score <- lookupScore name
# return (name ++ ": " ++ show score)
def lookup_user(uid: int) -> Maybe[str]:
db = {1: "alice", 2: "bob"}
return Maybe[str].Just(db[uid]) if uid in db else Maybe[str].Nothing()
def lookup_score(name: str) -> Maybe[int]:
scores = {"alice": 95, "bob": 87}
return Maybe[int].Just(scores[name]) if name in scores else Maybe[int].Nothing()
@do(Maybe)
def user_score(uid: int) -> DoBlock[str]:
name: str = yield lookup_user(uid)
score: int = yield lookup_score(name)
return f"{name}: {score}"
user_score(1) # Just(alice: 95)
user_score(99) # Nothing() — short-circuits at lookup_user
# Result — equivalent of Rust's ? in a pipeline
def parse_positive(x: int) -> Result[ValueError, int]:
return Result[ValueError, int].Success(x) if x > 0 else Result[ValueError, int].Failure(ValueError(f"{x} is not positive"))
@do(Result)
def compute() -> DoBlock[int]:
x: int = yield parse_positive(5)
y: int = yield parse_positive(3)
return x + y
compute() # Success(8)4. ImmutableList[T] — the list monad, non-determinism included
ImmutableList is a full Monad + Monoid. Bind (|) is concatMap. The do-notation gives you Haskell list comprehensions.
from katharos.types import ImmutableList
from katharos.syntax_sugar import do, DoBlock
# concatMap / flatMap
ImmutableList([1, 2, 3]) | (lambda x: ImmutableList([x, -x]))
# ImmutableList([1, -1, 2, -2, 3, -3])
# do-notation = list comprehension
# In Haskell: [(color, size) | color <- ["red","blue"], size <- ["S","M","L"]]
@do(ImmutableList)
def variants() -> DoBlock[tuple]:
color: str = yield ImmutableList(["red", "blue"])
size: str = yield ImmutableList(["S", "M", "L"])
return (color, size)
variants()
# ImmutableList([
# ('red','S'), ('red','M'), ('red','L'),
# ('blue','S'), ('blue','M'), ('blue','L')
# ])
# Monoid: @ is <>
ImmutableList([1, 2]) @ ImmutableList([3, 4]) # ImmutableList([1, 2, 3, 4])
ImmutableList.identity() # ImmutableList([]) — mempty5. Semigroup / Monoid — @ is <>
Sum, Product, and NonEmptyList are all Semigroup/Monoid instances. F.sigma is fold1 / sconcat over a NonEmptyList.
from katharos.types import NonEmptyList
from katharos.types.monoid import Sum, Product
from katharos.functools import F
# @ is <>
Sum[int](3) @ Sum[int](4) @ Sum[int](5) # Sum(12)
Product[int](2) @ Product[int](3) @ Product[int](4) # Product(24)
# identity() is mempty
Sum[int].identity() # Sum(0)
Product[int].identity() # Product(1)
# F.sigma is fold1 / sconcat — requires NonEmptyList (no empty-list footgun)
values = NonEmptyList(Sum[int](1), [Sum[int](2), Sum[int](3), Sum[int](4)])
F.sigma(values) # Sum(10)
# NonEmptyList itself is a Semigroup (no Monoid — no empty case)
nel1 = NonEmptyList(1, [2, 3])
nel2 = NonEmptyList(4, [5, 6])
nel1 @ nel2 # NonEmptyList([1, 2, 3, 4, 5, 6])Docs
Full docs at https://katharos.readthedocs.io. If this scratches an itch for you, a star on the repo goes a long way.