Search code examples
functional-programmingsanctuary

How do I collapse Maybe monads in sanctuary js


Here is a simple chained expression using modern javascript to find the value for a specific key located in a string containing a comma separated list of key-value pairs separated by =.

This falls down if the source is null or the key is not found, in my head this seemed like a great task for the Maybe monad.

// Grab the tag with key in `tag`
const getTag = (product, tag) =>
  product.Tags
    .split(',')
    .find(t => t.startsWith(`${tag}=`))
    .split('=')[1]

getTag({Tags: 'a=y,b=z'}, 'a') // returns 'y'
getTag({Tags: 'a=y,b=z'}, 'z') // returns boom (desired null)
getTag({Tags: null}, 'a') // returns boom (desired null)

So I npm installed sanctuary and began playing with a functional solution. This is as far as I've gotten so far and fee like it's pretty ugly, this tells me I must be doing something wrong or using the wrong tools.

const getk = S.map(S.filter(S.test(/=/)))(S.splitOn(','))

S.map(S.map(S.map(S.splitOn('='))))(S.map(getk))(S.toMaybe(null))
// Nothing
S.map(S.map(S.map(S.splitOn('='))))(S.map(getk))(S.toMaybe('a=y,b=z'))
//Just ([["a", "y"], ["b", "z"]])

I didn't want this to be a "solve this problem for me" question, but I am having a difficult time conveying what it is that I actually need help on.

N.B. I'm still trying to "figure out" FP, so this is definitely a problem of familiarity.


Solution

  • We can use S.map to transform inner values and S.join to remove unwanted nesting:

    const S = require ('sanctuary');
    const $ = require ('sanctuary-def');
    
    //    getTag :: String -> Object -> Maybe String
    const getTag = tag => S.pipe ([
      S.get (S.is ($.String)) ('Tags'),             // :: Maybe String
      S.map (S.splitOn (',')),                      // :: Maybe (Array String)
      S.map (S.map (S.stripPrefix (tag + '='))),    // :: Maybe (Array (Maybe String))
      S.map (S.head),                               // :: Maybe (Maybe (Maybe String))
      S.join,                                       // :: Maybe (Maybe String)
      S.join,                                       // :: Maybe String
    ]);
    
    getTag ('a') ({Tags: 'a=y,b=z'});   // => Just ('y')
    getTag ('z') ({Tags: 'a=y,b=z'});   // => Nothing
    getTag ('z') ({Tags: null});        // => Nothing
    

    S.map followed by S.join is always equivalent to S.chain:

    //    getTag :: String -> Object -> Maybe String
    const getTag = tag => S.pipe ([
      S.get (S.is ($.String)) ('Tags'),             // :: Maybe String
      S.map (S.splitOn (',')),                      // :: Maybe (Array String)
      S.map (S.map (S.stripPrefix (tag + '='))),    // :: Maybe (Array (Maybe String))
      S.chain (S.head),                             // :: Maybe (Maybe String)
      S.join,                                       // :: Maybe String
    ]);
    

    This approach does a bit of unnecessary work by not short-circuiting, but S.stripPrefix allows us, in a single step, to check whether the tag exists and extract its value if it is. :)

    Updated version which uses S.justs to select the first match:

    //    getTag :: String -> Object -> Maybe String
    const getTag = tag => S.pipe ([
      S.get (S.is ($.String)) ('Tags'),             // :: Maybe String
      S.map (S.splitOn (',')),                      // :: Maybe (Array String)
      S.map (S.map (S.stripPrefix (tag + '='))),    // :: Maybe (Array (Maybe String))
      S.map (S.justs),                              // :: Maybe (Array String)
      S.chain (S.head),                             // :: Maybe String
    ]);