I wanted a type-safe pipe
utility to help me write Python in a more functional style, but unfortunately toolz.pipe
and returns.pipelines.flow
both output Any
.
Happily, it turns out creating your own pipe mechanism is a one-liner:
from functools import reduce
result = reduce(lambda acc, f: f(acc), (fn1, fn2, fn3), value)
Which you can reuse by wrapping it in a function:
from functools import reduce
from typing import Callable, TypeVar
_A = TypeVar("A")
_B = TypeVar("B")
def pipe(value: _A, *functions: Callable[[_A], _A]) -> _A:
"""Pass a value through a series of functions that expect one argument of the same type."""
return reduce(lambda acc, f: f(acc), functions, value)
And calling it with any number of functions:
assert pipe("1", int, float, str) == "1.0"
# => i.e. str(float(int('1')))
# => i.e. int("1") -> float(1) -> str(1.0) -> "1.0"
So you can stop thinking up names for throwaway variables like these:
def str_to_float_str(value: string):
as_integer = int(value)
as_float = float(as_integer)
as_string = str(as_float)
return as_string
assert str_to_float_str("1") == "1.0"
Credit to Statically-Typed Functional Lists in Python with Mypy by James Earl Douglas and the returns.pipeline.flow
source code for the inspiration.
Update: Nov 29, 2024
While the pipe
function above with the Callable[[A], A]
type hint works fine if every function in the pipeline outputs the same type (A
), the example I showed above doesn’t actually work out very well! Mypy notices that some of the functions (int
and float
) output a different type than we started with (str
), so we aren’t actually passing A
all the way through.
After trying a number of workarounds (and getting some good advice on Reddit), I learned that you can either tell Mypy what’s going on by painstakingly articulating every possible overload:
_A = TypeVar("A")
_B = TypeVar("B")
_C = TypeVar("C")
_D = TypeVar("D")
_E = TypeVar("E")
@overload
def pipe(value: _A) -> _A: ...
@overload
def pipe(value: _A, f1: Callable[[_A], _B]) -> _B: ...
@overload
def pipe(
value: _A,
f1: Callable[[_A], _B],
f2: Callable[[_B], _C]
) -> _C: ...
@overload
def pipe(
value: _A,
f1: Callable[[_A], _B],
f2: Callable[[_B], _C],
f3: Callable[[_C], _D]
) -> _D: ...
@overload
def pipe(
value: _A,
f1: Callable[[_A], _B],
f2: Callable[[_B], _C],
f3: Callable[[_C], _D],
f4: Callable[[_D], _E],
) -> _E: ...
def pipe(value: Any, *functions: Callable[[Any], Any]) -> Any:
return reduce(lambda acc, f: f(acc), functions, value)
Or, you can just use expression, which already does this for you.
I’m going to do the latter, but this was a fun exercise in the meantime. 😎