javascriptfunctional-programmingmonads

These concepts about Functors (Maps) and Monads (Either, Maybe, Bind, Then) are right?


I'm relatively new to studying functional programming and things were going well until I had to treat errors and promises. Trying to do it in the “right” way I get a lot of references for Monads as to be the better solution for it, but while studying it I ended up with what I honestly would call a "reference hell", where there was a lot of references and sub-references either mathematical or in programming for the same thing where this thing would have a different name for the same concepts, which was really confusing. So after persisting in the subject, I'm now trying to summarize and clarify it, and this is what I get so far:

For the sake of understanding I'll oversimplify it.

Monoids: are anything that concatenate/sum two things returning a thing of the same group so in JS any math addition or just concatenation from a string are Monoids for definition as well the composition of functions.

Maps: Maps are just methods that apply a function to each element of a group, without changing the category of the group itself or its length.

Functors: Functors are just objects that have a Map method that return the Functor itself.

Monads: Monads are Functors that use FlatMaps.

FlatMaps: FlatMaps are maps that have the capability of treat promises/fetch or summarize the received value.

Either, Maybe, Bind, Then: are all FlatMaps but with different names depending on the context you use it.

(I think they all are FlatMaps for definition but there is a difference in the way they are used since there is a library like Monets.js that have both a Maybe and a Either function, but I don’t get the use case difference).

So my question is: are these concepts right?

if anyone can reassure me what so far I got right and correct what I got wrong, or even expand on what I have missed, I would be very grateful.

Thanks to anyone who takes the time.

//=============================================================//

EDIT: I should've emphasized more in this post, but these affirmations and simplified definitions are only from the "practical perspective in JavaScript" (I'm aware of the impossibility of making such a small simplification of a huge and complex theme like these especially if you add another field like mathematics).

//=============================================================//


Solution

  • To be blunt, I think all of your definitions are pretty terrible, except maybe the "monoid" one.

    Here's another way of thinking about these concepts. It's not "practical" in the sense that it will tell you exactly why a flatmap over the list monad should flatten nested lists, but I think it's "practical" in the sense that it should tell you why we care about programming with monads in the first place, and what monads in general are supposed to accomplish from a practical perspective within functional programs, whether they are written in JavaScript or Haskell or whatever.

    In functional programming, we write functions that take certain types as input and produce certain types as output, and we build programs by composing functions whose input and output types match. This is an elegant approach that results in beautiful programs, and it's one of the main reasons functional programmers like functional programming.

    Functors provide a way to systematically transform types in a way that adds useful functionality to the original types. For example, we can use a functor to add functionality to a "normal type" that allows it to be missing or absent or "null" (Maybe) or represent either a successfully computed result or an error condition (Either) or that allows it to represent multiple possible values instead of only one (list) or that allows it to be computed at some time in the future (promise) or that requires a context for evaluation (Reader), or that allows a combination of these things.

    A map allows us to reuse the functions we've defined for normal types on these new types that have been transformed by a functor, in some natural way. If we already have a function that doubles an integer, we can re-use that function on various functor transformations of integers, like doubling an integer that might be missing (mapping over a Maybe) or doubling an integer that hasn't been computed yet (mapping over a promise) or doubling every element of a list (mapping over a list).

    A monad involves applying the functor concept to the output types of functions to produce "operations" that have additional functionality. With monad-less functional programming, we write functions that take "normal types" of inputs and produce "normal types" of outputs, but monads allow us to take "normal types" of inputs and produce transformed types of outputs, like the ones above. Such a monadic operation can represent a function that takes an input and Maybe produces an output, or one that takes an input and promises to produce an output later, or that takes an input and produces a list of outputs.

    A flatmap generalizes the composition of functions on normal types (i.e., the way we build monad-less functional programs) to composition of monadic operations, appropriately "chaining" or combining the extra functionality provided by the transformed output types of the monadic operations. So, flatmaps over the maybe monad will compose functions as long as they keep producing outputs and give up when one of those functions has a missing output; flatmaps over the promise monad will turn a chain of operations that each take an input and promise an output into a single composed operation that takes an input and promises a final output; flatmaps over the list monad will turn a chain of operations that each take a single input and produce multiple outputs into a single composed operation that takes an input and produces multiple outputs.

    Note that these concepts are useful because of their convenience and the systematic approach they take, not because they add magic functionality to functional programs that we wouldn't otherwise have. Of course we don't need a functor to create a list data type, and we don't need a monad to write a function that takes a single input and produces a list of outputs. It just ends up being useful thinking in terms of "operations that take an input and promise to produce either an error message or a list of outputs", compose 50 of those operations together, and end up with a single composed operation that takes an input and promises either an error message or a list of outputs (without requiring deeply nested lists of nested promises to be manually resolved -- hence the value of "flattening").

    (In practical programming terms, monoids don't have that much to do with the rest of these, except to make hilarious in-jokes about the category of endofunctors. Monoids are just a systematic way of combining or "reducing" a bunch of values of a particular type into a single value of that type, in a manner that doesn't depend on which values are combined first or last.)

    In a nutshell, functors and their maps allow us to add functionality to our types, while monads and their flatmaps provide a mechanism to use functors while retaining some semblance of the elegance of simple functional composition that makes functional programming so enjoyable in the first place.

    An example might help. Consider the problem of performing a depth-first traversal of a file tree. In some sense, this is a simple recursive composition of functions. To generate a filetree() rooted at pathname, we need to call a function on the pathname to fetch its children(), and then we need to recursively call filetree() on those children(). In pseudo-JavaScript:

    // to generate a filetree rooted at a pathname...
    function filetree(pathname) {
        // we need to get the children and generate filetrees rooted at their pathnames
        filetree(children(pathname))
    }
    

    Obviously, though, this won't work as real code. For one thing, the types don't match. The filetree function should be called on a single pathname, but children(pathname) will return multiple pathnames. There are also some additional problems -- it's unclear how the recursion is supposed to stop, and there's also the issue that the original pathname appears to get lost in the shuffle as we jump right to its children and their filetrees. Plus, if we're trying to integrate this into an existing Node application with a promise-based architecture, it's unclear how this version of filetree could support the promise-based filesystem API.

    But, what if there was a way to add functionality to the types involved while maintaining the elegance of this simple composition? For example, what if we had a functor that allowed us to promise to return multiple values (e.g., multiple child pathnames) while logging strings (e.g., parent pathnames) as a side effect of the processing?

    Such a functor would, as I've said above, be a transformation of types. That means that it would transform a "normal" type, like a "integer", into a "promise for a list of integers together with a log of strings". Suppose we implement this as an object containing a single promise:

    function M(promise) {
        this.promise = promise
    }
    

    which when resolved will yield an object of form:

    {
      "data": [1,2,3,4]  // a list of integers
      "log": ["strings","that","have","been","logged"]
    }
    

    As a functor, M would have the following map function:

    M.prototype = {
        map: function(f) {
            return this.promise.then((obj) => ({
                data: obj.data.map(f),
                log: obj.log
            }))
        }
    }
    

    which would apply a plain function to the promised data (without affecting the log).

    More importantly, as a monad, M would have the following flatMap function:

    M.prototype = {
        ...
        flatMap: function(f) {
            // when the promised data is ready
            return new M(this.promise.then(function(obj) {
                // map the function f across the data, generating promises
                var promises = obj.data.map((d) => f(d).promise)
                // wait on all promises
                return Promise.all(promises).then((results) => ({
                    // flatten all the outputs
                    data: results.flatMap((result) => result.data),
                    // add to the existing log
                    log: obj.log.concat(results.flatMap((result) => result.log))
                }))
            }))
        }
    }
    

    I won't explain in detail, but the idea is that if I have two monadic operations in the M monad, that take a "plain" input and produce an M-transformed output, representing a promise to provide a list of values together with a log, I can use the flatMap method on the output of the first operation to compose it with the second operation, yielding a composite operation that takes a single "plain" input and produces an M-transformed output.

    By defining children as a monadic operation in the M monad that promises to take a parent pathname, write it to the log, and produce a list of the children of this pathname as its output data:

    function children(parent) {
        return new M(fsPromises.lstat(parent)
                     .then((stat) => stat.isDirectory() ? fsPromises.readdir(parent) : [])
                     .then((names) => ({
                         data: names.map((x) => path.join(parent, x)),
                         log: [parent]
                     })))
    }
    

    I can write the recursive filetree function almost as elegantly as the original above, as a flatMap-assisted composition of the children and recursively invoked filetree functions:

    function filetree(pathname) {
        return children(pathname).flatMap(filetree)
    }
    

    In order to use filetree, I need to "run" it to extract the log and, say, print it to the console.

    // recursively list files starting at current directory
    filetree(".").promise.then((x) => console.log(x.log))
    

    The full code is below. Admittedly, there's a fair bit of it, and some of it is pretty complicated, so the elegance of the filetree function appears to have come at a fairly big cost, as we've apparently just moved all the complexity (and them some) into the M monad. However, the M monad is a general tool, not specific to performing depth-first traversals of file trees. Also, in an ideal world, a sophisticated JavaScript monad library would allow you to build the M monad from monadic pieces (promise, list, and log) with a couple lines of code.

    var path = require('path')
    var fsPromises = require('fs').promises
    
    function M(promise) {
        this.promise = promise
    }
    M.prototype = {
        map: function(f) {
            return this.promise.then((obj) => ({
                data: obj.data.map(f),
                log: obj.log
            }))
        },
        flatMap: function(f) {
            // when the promised data is ready
            return new M(this.promise.then(function(obj) {
                // map the function f across the data, generating promises
                var promises = obj.data.map((d) => f(d).promise)
                // wait on all promises
                return Promise.all(promises).then((results) => ({
                    // flatten all the outputs
                    data: results.flatMap((result) => result.data),
                    // add to the existing log
                    log: obj.log.concat(results.flatMap((result) => result.log))
                }))
            }))
        }
    }
    // not used in this example, but this embeds a single value of a "normal" type into the M monad
    M.of = (x) => new M(Promise.resolve({ data: [x], log: [] }))
    
    function filetree(pathname) {
        return children(pathname).flatMap(filetree)
    }
    function children(parent) {
        return new M(fsPromises.lstat(parent)
                     .then((stat) => stat.isDirectory() ? fsPromises.readdir(parent) : [])
                     .then((names) => ({
                         data: names.map((x) => path.join(parent, x)),
                         log: [parent]
                     })))
    }
    
    // recursively list files starting at current directory
    filetree(".").promise.then((x) => console.log(x.log))