Skip to content

Functional Anti-Patterns

Common mistakes when adopting functional programming in Python.

1. Wrapping everything in a monad

# BAD — Option adds nothing here
def add(a: int, b: int) -> Option[int]:
    return Some(a + b)

# GOOD — plain function, can't fail
def add(a: int, b: int) -> int:
    return a + b

Use monads at boundaries — where things can actually fail, be absent, or produce side effects. Pure computations don't need wrapping.

Rule of thumb: if a function can't fail, return a plain value.

2. Catching and re-wrapping known exceptions

# BAD — catching what you just threw
def get_user(id: int) -> Result[User]:
    try:
        user = db.query(id)
        if user is None:
            raise ValueError("not found")
        return Ok(user)
    except ValueError as e:
        return Err(e)

# GOOD — use Result directly, no exceptions
def get_user(id: int) -> Result[User]:
    user = db.query(id)
    if user is None:
        return Err(ValueError("not found"))
    return Ok(user)

# BEST — use @Try only at the boundary with exception-throwing code
@Try
def get_user(id: int) -> User:
    return db.query_or_raise(id)  # let @Try catch it

@Try is for wrapping code you don't control. If you control the logic, return Ok/Err directly.

3. Nested monads without transformers or unification

# BAD — Future[Option[Result[User]]]
def get_user(id) -> Future[Option[Result[User]]]:
    ...
# Now every caller unwraps 3 layers

# GOOD — pick ONE monad for your pipeline
def get_user(id) -> AsyncResult[User]:
    ...
# Clean composition: .bind(), .map(), @do

Don't stack monads. Either use a transformer (OptionT, EitherT) or unify on a single type (usually AsyncResult or Result).

4. Overly abstract code

# BAD — abstraction for abstraction's sake
def process(F: Monad, G: MonadError, H: Traversable, fa):
    return G.handle_error_with(
        H.traverse(fa, lambda x: F.map(F.pure(x), str), F),
        lambda e: F.pure("default")
    )

# GOOD — just write the concrete code
def process(items: CList[int]) -> Option[CList[str]]:
    return Option.traverse(items, lambda x: Some(str(x)))

Use typeclass abstraction (summon, F: Monad) when you actually need to support multiple types. For one concrete type, just use it directly.

Rule: if your function only ever runs with Option, type it as Option, not F: Monad.

5. do-notation for one or two steps

# BAD — do-notation overhead for a single bind
@Result.do
def pipeline():
    x = yield Ok(10)
    return x + 1

# GOOD — just use map
Ok(10).map(lambda x: x + 1)

Do-notation pays off at 3+ steps. For 1-2 steps, map or bind is clearer and faster (~2x, see benchmarks).

6. Pattern matching where map/bind suffice

# BAD — manual pattern matching
match get_user(id):
    case Some(user):
        match get_email(user):
            case Some(email):
                return Some(email.upper())
            case Nothing():
                return Nothing()
    case Nothing():
        return Nothing()

# GOOD — let bind handle the short-circuiting
get_user(id).bind(get_email).map(str.upper)

Pattern matching is for consuming a final result. For composing operations, use map/bind — that's what they're for.

7. Mutation hidden inside monadic code

# BAD — mutating a shared list inside map
results = []
def collect(x):
    results.append(x)  # MUTATION — defeats the purpose
    return x

Some(10).map(collect)  # side effect hidden inside "functional" code

# GOOD — use Writer for accumulation
@TraceWriter.do
def pipeline():
    x = yield TraceWriter(10, ["started"])
    y = yield TraceWriter(x + 1, ["incremented"])
    return y

If you need to accumulate, use Writer. If you need state, use State. Don't smuggle mutation inside map/bind.

8. Ignoring the error rail

# BAD — extracting with get_or_else immediately
user = get_user(id).get_or_else(default_user)
email = get_email(user).get_or_else("unknown@unknown.com")
prefs = get_prefs(user).get_or_else(default_prefs)

# GOOD — stay on the rails, unwrap once at the boundary
@Result.do
def pipeline(id):
    user = yield get_user(id)
    email = yield get_email(user)
    prefs = yield get_prefs(user)
    return Profile(user, email, prefs)

match pipeline(id):
    case Ok(profile): render(profile)
    case Err(e): render_error(e)

get_or_else is for the boundary — where you finally need a plain value. Inside a pipeline, stay monadic and let errors propagate.

9. Creating typeclasses for things that aren't typeclasses

# BAD — this is just a regular interface
class UserService(BaseTypeclass):
    @abstractmethod
    def get_user(self, id): ...
    @abstractmethod
    def save_user(self, user): ...

# GOOD — this is a regular Protocol/ABC, not a typeclass
class UserService(Protocol):
    def get_user(self, id: int) -> Result[User]: ...
    def save_user(self, user: User) -> Result[None]: ...

Typeclasses are for capabilities that multiple unrelated types share (Functor, Monad, Ordering). Business interfaces (UserService, EmailSender) are just Protocols/ABCs.

Typeclass test: "Does int, str, AND User need this?" If yes, it might be a typeclass. If only UserService needs it, it's a Protocol.

10. Premature typeclass abstraction

# BAD — abstracting over the effect type when you have one effect
def get_user(F: MonadError, id: int):
    ...
def save_user(F: MonadError, user: User):
    ...
# Every function takes F — massive boilerplate for no benefit

# GOOD — use Result directly until you actually need polymorphism
def get_user(id: int) -> Result[User]:
    ...
def save_user(user: User) -> Result[None]:
    ...

Use F: Monad when you have multiple callers that need different effects (tagless final, library code). For application code with one effect type, just use it directly.

Summary

Anti-pattern Fix
Wrapping pure functions in monads Return plain values
Catching your own exceptions Return Ok/Err directly
Nested monads Unify on one type or use transformers
Over-abstraction Use concrete types until you need polymorphism
do-notation for 1-2 steps Use map/bind directly
Pattern matching for composition Use map/bind
Hidden mutation Use Writer/State
Immediate get_or_else Stay monadic, unwrap at boundary
Business interfaces as typeclasses Use Protocol/ABC
Premature typeclass abstraction Use concrete types first