4

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.

joshperry
  • 41,167
  • 16
  • 88
  • 103

2 Answers2

4

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
]);
davidchambers
  • 23,918
  • 16
  • 76
  • 105
  • Ok, I was wondering if pipe was a common idiom. Thank you for your analysis, this has been a most interesting and informative experience. – joshperry Aug 11 '18 at 18:09
  • I like a lot of the small nuggets here: currying the predicate tag (original order of arguments were backwards), using pipe for executing serial operations (instead of "nesting"?), returning a `Maybe String` (conversion outside the function makes much more sense for reuse), using `get` to nab nullable/undefined object props (using `toMaybe` felt brute force entry of data into "monad space"), and the `stripPrefix` optimization (uses the Maybe String return to great effect in the pipeline). All of this together with `chain` and `join` minimizes complexity a lot. – joshperry Aug 11 '18 at 18:24
  • Do you regularly write step-by-step type info next to your function pipes like this? – joshperry Aug 11 '18 at 18:27
  • I don't think `head` is working the way you expect. `getTag('b', {Tags: 'a=y,b=z'}) // Nothing` – joshperry Aug 11 '18 at 19:19
  • I used `S.justs` when writing the function initially, but must have botched my refactoring. I've added a working implementation to my answer. As for type signatures, I often annotate functions but rarely annotate each step in a pipeline (although I like to do so in presentation slides and in answers on Stack Overflow). – davidchambers Aug 11 '18 at 22:38
1

Here's an alternative to Sanctuary code just using modern JavaScript:

const stripPrefix = e =>
    e.startsWith(`${tag}=`)
        && e.replace(`${tag}=`, "")

const getTag = tag => product =>
    product?.Tags
        ?.split(",")
        .map(stripPrefix)
        .filter(Boolean)
        [0]
    || null


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

The stripPrefix function returns false if the tag is not found, which then gets filtered.

And you can deal with { Tags: null } by using optional chaining operator (?.).

lukas
  • 579
  • 2
  • 8
  • 17
  • Short and sweet. Once more the proof that savvy JS developers don't need exotic straitjacket libraries to do functional programming, wishing there were native types to save from design errors. Lukas, would you mind getting in touch? I have a few vanilla-JS FP projects and you could be a good partner to bounce ideas. Please, do. – Marco Faustinelli Apr 04 '20 at 17:43