To the best of my understanding Monads were created to allow for composing functions with those that had potential side-effects - loosely speaking.
For me composition implies code like so:
f(g(h(x)))
In order to achieve this in a programming language one has to "line up the types" correctly, so that the output of h(x)
is an input to g(...)
. Implying that such a function chaining would require all functions in the chain to work at the "monadic level of abstraction" for the types to line up correctly.
However, at my workplace (and some library code) I see a lot of code that looks more like "function chaining" like so:
h(x).flatMap(g).map(f)
This is NOT function composition AFAIK and this probably makes code harder to read IMHO since there's cognitive overload in understanding "type translation" with flatMap/map
thrown into the mix. One has to mentally unravel the computations to see how they all "line up".
What is the common convention in the FP-world? I had a few discussions with my peers and got extremely strong push back for the compositional style f(g(h(...
- almost everyone preferred the "chaining style". Is there a common "style guide" that's advocated for something like this?
From my POV, I've been exposed to LISP/Scheme and f(g(h...
isn't really alien and is rather more clean and reads like a DSL. The chaining is rather hacky.
Question: Should functions work at the monadic level to allow for composition or is the suggestion to work at the level of the wrapped value?
Concrete example:
checkForBlanks(csvRows).flatMap(checkForUniqueIds).map(buildCache))
vs
buildCache(checkForUniqueIds(checkForBlanks(csvRows)))
Method signatures (Non-monadic):
def checkForBlanks(csvRecords: Vector[Record]): Either[InternalDomainError, Vector[Record]]
def checkForUniqueIds(csvRecords: Vector[Record]): Either[InternalDomainError, Vector[Record]]
def buildCache(csvRows: Vector[Record]): MyCache
Method Signatures (Monadic):
def checkForBlanks(csvRecords: Vector[Record]): Either[InternalDomainError, Vector[Record]]
def checkForUniqueIds(data: Either[InternalDomainError, Vector[Record]]): Either[InternalDomainError, Vector[Record]]
def buildCache(data: Either[InternalDomainError, Vector[Record]]): MyCache
Common points for pushback:
- Composing forces reading right to left
- Composing makes functions think of Monads and will clutter responsibility of handling wrappers
- It's easier if a function just works on the "actual value" vs. a monadic wrapper since it's "easier to reason"
- It's way more flexible to "chain" than compose
- If you really want to "compose" add additional methods that "call out" to pure methods and interally wrap monads - unnecessarily complicated so don't do it: E.g.:
def uniq(data: Either[InternalDomainError, Vector[Record]]): Either[InternalDomainError, Vector[Record]] = data.flatMap(checkForUniqueIds)
From an FP-adherence and best practices POV what's the recommendation on should one do this for readability/maintainability?