6

I'm building a PWA and build by logic with Ramda. I'm trying to build a function that given a Google Places Detail response returns a custom address object.

Let me describe it in code by showing you my test:

assert({
  given: 'a google places api response from Google Places',
  should: 'extract the address',
  actual: getAddressValues({
    address_components: [
      {
        long_name: '5',
        short_name: '5',
        types: ['floor'],
      },
      {
        long_name: '48',
        short_name: '48',
        types: ['street_number'],
      },
      {
        long_name: 'Pirrama Road',
        short_name: 'Pirrama Rd',
        types: ['route'],
      },
      {
        long_name: 'Pyrmont',
        short_name: 'Pyrmont',
        types: ['locality', 'political'],
      },
      {
        long_name: 'Council of the City of Sydney',
        short_name: 'Sydney',
        types: ['administrative_area_level_2', 'political'],
      },
      {
        long_name: 'New South Wales',
        short_name: 'NSW',
        types: ['administrative_area_level_1', 'political'],
      },
      {
        long_name: 'Australia',
        short_name: 'AU',
        types: ['country', 'political'],
      },
      {
        long_name: '2009',
        short_name: '2009',
        types: ['postal_code'],
      },
    ],
    geometry: {
      location: {
        lat: -33.866651,
        lng: 151.195827,
      },
      viewport: {
        northeast: {
          lat: -33.8653881697085,
          lng: 151.1969739802915,
        },
        southwest: {
          lat: -33.86808613029149,
          lng: 151.1942760197085,
        },
      },
    },
  }),
  expected: {
    latitude: -33.866651,
    longitude: 151.195827,
    city: 'Pyrmont',
    zipCode: '2009',
    streetName: 'Pirrama Road',
    streetNumber: '48',
  },
});

As you can see my desired address object is more "flat" (for a lack of a better term). I'm struggling to write this transformation function. I tried doing it using Ramda's evolve, but that keeps the keys. I would need to transform the object using evolve and then reduce the object spreading the keys.

// Pseudo
({ address_components }) => ({ ...address_components })

I successfully extract the relevant information using evolve and renamed the keys using renameKeys from Ramda adjunct, but I can't figure out how to flatten that object afterwards. How do you do that? Or is there maybe an even easier way to achieve the desired transformation?

Edit:

I found a way to achieve my transformation, but its very verbose. I feel like there is an easier way to extract the address data. Anyways, here is my current solution:

export const getAddressValues = pipe(
  evolve({
    address_components: pipe(
      reduce(
        (acc, val) => ({
          ...acc,
          ...{
            [head(prop('types', val))]: prop('long_name', val),
          },
        }),
        {}
      ),
      pipe(
        pickAll([
          'route',
          'locality',
          'street_number',
          'country',
          'postal_code',
        ]),
        renameKeys({
          route: 'streetName',
          locality: 'city',
          street_number: 'streetNumber',
          postal_code: 'zipCode',
        }),
        map(ifElse(isNil, always(null), identity))
      )
    ),
    geometry: ({ location: { lat, lon } }) => ({
      latitude: lat,
      longitude: lon,
    }),
  }),
  ({ address_components, geometry }) => ({ ...address_components, ...geometry })
);

Edit: Based on @codeepic's answer, here is the plain JavaScript solution that I ended up using (though @user3297291's is elegant and I love it):

const getLongNameByType = (arr, type) => 
  arr.find(o => o.types.includes(type)).long_name;

const getAddressValues = ({ address_components: comp, geometry: { location: { lat, lng } } }) => ({
  latitude: lat,
  longitude: lng,
  city: getLongNameByType(comp, 'locality'),
  zipCode: getLongNameByType(comp, 'postal_code'),
  streetName: getLongNameByType(comp, 'route'),
  streetNumber: getLongNameByType(comp, 'street_number'),
  country: getLongNameByType(comp, 'country'),
});
J. Hesters
  • 13,117
  • 31
  • 133
  • 249
  • The proper term is: You want to fold or reduce a (multi-way) tree –  Jun 04 '19 at 07:28
  • @bob Thank you! I'll google these terms and see if I can improve my code. – J. Hesters Jun 04 '19 at 07:33
  • 1
    Am I missing the point here? You can transform input object into output object using way fewer lines of JavaScript. Given the slightly unusual shape of the input object I would write a generic filter function that checks `types` array in each child object and if it's a match, it picks up the object's property you are looking for. – codeepic Jun 04 '19 at 09:00

4 Answers4

3

Lenses are probably your best bet for this. Ramda has a generic lens function, and specific ones for an object property (lensProp), for an array index (lensIndex), and for a deeper path (lensPath), but it does not include one to find a matching value in an array by id. It's not hard to make our own, though.

A lens is made by passing two functions to lens: a getter which takes the object and returns the corresponding value, and a setter which takes the new value and the object and returns an updated version of the object.

Here we write lensMatch which find or sets the value in the array where a given property name matches the supplied value. And lensType simply passes 'type' to lensMatch to get back a function which will take an array of types and return a lens.

Using any lens, we have the view, set, and over functions which, respectively, get, set, and update the value.

const lensMatch = (propName) => (key) => lens ( 
  find ( propEq (propName, key) ),
  (val, arr, idx = findIndex (propEq (propName, key), arr)) =>
     update(idx > -1 ? idx : length(arr), val, arr)
)
const lensTypes = lensMatch ('types')
const longName = (types) => 
  compose (lensProp ('address_components'), lensTypes (types), lensProp ('long_name'))
// can define `shortName` similarly if needed

const getAddressValues = applySpec ( {
  latitude:     view (lensPath (['geometry', 'location', 'lat']) ),
  longitude:    view (lensPath (['geometry', 'location', 'lng']) ),
  city:         view (longName (['locality', 'political']) ),
  zipCode:      view (longName (['postal_code']) ),
  streetName:   view (longName (['route']) ),
  streetNumber: view (longName (['street_number']) ),
})

const response = {"address_components": [{"long_name": "5", "short_name": "5", "types": ["floor"]}, {"long_name": "48", "short_name": "48", "types": ["street_number"]}, {"long_name": "Pirrama Road", "short_name": "Pirrama Rd", "types": ["route"]}, {"long_name": "Pyrmont", "short_name": "Pyrmont", "types": ["locality", "political"]}, {"long_name": "Council of the City of Sydney", "short_name": "Sydney", "types": ["administrative_area_level_2", "political"]}, {"long_name": "New South Wales", "short_name": "NSW", "types": ["administrative_area_level_1", "political"]}, {"long_name": "Australia", "short_name": "AU", "types": ["country", "political"]}, {"long_name": "2009", "short_name": "2009", "types": ["postal_code"]}], "geometry": {"location": {"lat": -33.866651, "lng": 151.195827}, "viewport": {"northeast": {"lat": -33.8653881697085, "lng": 151.1969739802915}, "southwest": {"lat": -33.86808613029149, "lng": 151.1942760197085}}}}

console .log (
  getAddressValues (response)
)
<script src="//cdnjs.cloudflare.com/ajax/libs/ramda/0.26.1/ramda.js"></script><script>
const {applySpec, compose, find, findIndex, lens, lensProp, lensPath, propEq, update, view} = R  </script>

We could get away with a simpler version of lensMatch for this problem, as we are not using the setter:

const lensMatch = (propName) => (key) => 
  lens (find (propEq (propName, key) ), () => {} )

But I wouldn't recommend it. The full lensMatch is a useful utility function.

There are several ways we might want to change this solution. We could move the view inside longName and write another minor helper to wrap the result of lensPath in view to simplify the call to look more like this.

  longitude:    viewPath (['geometry', 'location', 'lng']),
  city:         longName (['locality', 'political']),

Or we could write a wrapper to applySpec, perhaps viewSpec which simply wrapped all the property functions in view. These are left as an exercise for the reader.


(The intro to this was barely modified from an earlier answer of mine.)


Update

I also tried an entirely independent approach. I think it's less readable, but it's probably more performant. It's interesting to compare the options.

const makeKey = JSON.stringify

const matchType = (name) => (
  spec,
  desc = spec.reduce( (a, [t, n]) => ({...a, [makeKey (t)]: n}), {})
) => (xs) => xs.reduce(
  (a, { [name]: fld, types }, _, __, k = makeKey(types)) => ({
    ...a,
    ...(k in desc ? {[desc[k]]: fld} : {})
  }), 
  {}
)
const matchLongNames = matchType('long_name')

const getAddressValues2 = lift (merge) (
  pipe (
    prop ('address_components'), 
    matchLongNames ([
      [['locality', 'political'], 'city'],
      [['postal_code'], 'zipCode'],
      [['route'], 'streetName'],
      [['street_number'], 'streetNumber'],
    ])
  ),
  applySpec ({
    latitude: path(['geometry', 'location', 'lat']),
    longitude: path(['geometry', 'location', 'lng']),
  })
)

const response = {"address_components": [{"long_name": "5", "short_name": "5", "types": ["floor"]}, {"long_name": "48", "short_name": "48", "types": ["street_number"]}, {"long_name": "Pirrama Road", "short_name": "Pirrama Rd", "types": ["route"]}, {"long_name": "Pyrmont", "short_name": "Pyrmont", "types": ["locality", "political"]}, {"long_name": "Council of the City of Sydney", "short_name": "Sydney", "types": ["administrative_area_level_2", "political"]}, {"long_name": "New South Wales", "short_name": "NSW", "types": ["administrative_area_level_1", "political"]}, {"long_name": "Australia", "short_name": "AU", "types": ["country", "political"]}, {"long_name": "2009", "short_name": "2009", "types": ["postal_code"]}], "geometry": {"location": {"lat": -33.866651, "lng": 151.195827}, "viewport": {"northeast": {"lat": -33.8653881697085, "lng": 151.1969739802915}, "southwest": {"lat": -33.86808613029149, "lng": 151.1942760197085}}}}

console .log (
  getAddressValues2 (response)
)
<script src="//cdnjs.cloudflare.com/ajax/libs/ramda/0.26.1/ramda.js"></script><script>
const {applySpec, lift, merge, path, pipe, prop} = R                          </script>

This version splits the problem in two: one for the easier fields, latitude and longitude, and one for the others, which are harder to match, then simply merges the result of applying each of those to the response.

The easier fields require no comment. It's just an easy application of applySpec and path. The other one, encapsulated as matchType accepts a specification matching types on the input (and the name of the field to extract) to the property names for the output. It builds an index, desc, based on the types (here using JSON.stringify, although there are clearly alternatives). It then reduces an array of objects finding any whose types property is in the index and ties its value with the appropriate field name.

It's an interesting variant. I still prefer my original, but for large arrays this might make a significant difference in performance.

Another Update

After reading the answer from user633183, I've been thinking about how I would like to use something like this. There'a a lot to be said for using Maybes here. But there are two distinct ways I would likely want to interact with the results. One lets me operate field-by-field, with each wrapped in its own Maybe. The other is as a complete object, having all its fields; but for the reasons demonstrated, it would have to be wrapped in its own Maybe.

Here is a different version that generates the first variant and includes a function to convert it into the second.

const maybeObj = pipe (
  toPairs,
  map(([k, v]) => v.isJust ? Just([k, v.value]) : Nothing()),
  sequence(Maybe),
  map(fromPairs)
)

const maybeSpec = (spec = {}) => (obj = {}) =>
  Object .entries (spec) .reduce (
    (a, [k, f] ) => ({...a, [k]: Maybe (is (Function, f) && f(obj))}), 
    {}
  )

const findByTypes = (types = []) => (xs = []) =>
  xs .find (x => equals (x.types, types) ) 

const getByTypes = (name) => (types) => pipe (
  findByTypes (types),
  prop (name)
)

const getAddressComponent = (types) => pipe (
  prop ('address_components'),
  getByTypes ('long_name') (types)
)
const response = {"address_components": [{"long_name": "5", "short_name": "5", "types": ["floor"]}, {"long_name": "48", "short_name": "48", "types": ["street_number"]}, {"long_name": "Pirrama Road", "short_name": "Pirrama Rd", "types": ["route"]}, {"long_name": "Pyrmont", "short_name": "Pyrmont", "types": ["locality", "political"]}, {"long_name": "Council of the City of Sydney", "short_name": "Sydney", "types": ["administrative_area_level_2", "political"]}, {"long_name": "New South Wales", "short_name": "NSW", "types": ["administrative_area_level_1", "political"]}, {"long_name": "Australia", "short_name": "AU", "types": ["country", "political"]}, {"long_name": "2009", "short_name": "2009", "types": ["postal_code"]}], "geometry": {"location": {"lat": -33.866651, "lng": 151.195827}, "viewport": {"northeast": {"lat": -33.8653881697085, "lng": 151.1969739802915}, "southwest": {"lat": -33.86808613029149, "lng": 151.1942760197085}}}}

getAddressComponent (['route']) (response)

const extractAddress = maybeSpec({
  latitude:     path (['geometry', 'location', 'lat']),
  longitude:    path (['geometry', 'location', 'lng']),
  city:         getAddressComponent (['locality', 'political']),
  zipCode:      getAddressComponent  (['postal_code']),
  streetName:   getAddressComponent  (['route']),
  streetNumber: getAddressComponent (['street_number']),  
})

const transformed = extractAddress (response)

// const log = pipe (toString, console.log)
const log1 = (obj) => console.log(map(toString, obj))
const log2 = pipe (toString, console.log)

// First variation
log1 (
  transformed
)

// Second variation
log2 (
  maybeObj (transformed)
)
<script src="https://bundle.run/ramda@0.26.1"></script>
<script src="https://bundle.run/ramda-fantasy@0.8.0"></script>
<script>
const {equals, fromPairs, is, map, path, pipe, prop, toPairs, sequence, toString} = ramda;
const {Maybe} = ramdaFantasy;
const {Just, Nothing} = Maybe;
</script>

The function maybeObj converts a structure like this:

{
  city: Just('Pyrmont'),
  latitude: Just(-33.866651)
}

into one like this:

Just({
  city: 'Pyrmont',
  latitude: -33.866651
})

but one with a Nothing:

{
  city: Just('Pyrmont'),
  latitude: Nothing()
}

back into a Nothing:

Nothing()

It acts for Objects much like R.sequence does for arrays and other foldable types. (Ramda, for long, complicated reasons, does not treat Objects as Foldable.)

The rest of this is much like the answer from @user633183, but written in my own idioms. Probably the only other part worth noting is maybeSpec, which acts much like R.applySpec but wraps each field in a Just or a Nothing.

(Note that I'm using the Maybe from Ramda-Fantasy. That project has been discontinued, and I probably should have figured out what changes were required to use one of the up-to-date projects out there. Blame it on laziness. The only change required, I imagine, would be to replace the calls to Maybe with whatever function they offer [or your own] to convert nil values to Nothing and every other one to Justs.)

Scott Sauyet
  • 49,207
  • 4
  • 49
  • 103
2

Not that much of an improvement maybe, but I have some suggestions:

  • You could use indexBy instead of the (kind of hard to read) inline reduce function.
  • By splitting the address and location logic, and making a composed helper to combine the two, it's easier to read what happens (using juxt and mergeAll)
  • You can use applySpec instead of pickAll + renameKeys

const { pipe, indexBy, prop, head, compose, path, map, applySpec, juxt, mergeAll } = R;

const reformatAddress = pipe(
  prop("address_components"),
  indexBy(
    compose(head, prop("types"))
  ),
  applySpec({
    streetName: prop("route"),
    city: prop("locality"),
    streetNumber: prop("street_number"),
    zipCode: prop("postal_code"),
  }),
  map(prop("long_name"))
);

const reformatLocation = pipe(
  path(["geometry", "location"]),
  applySpec({
    latitude: prop("lat"),
    longitude: prop("lng")
  })
);

// Could also be: converge(mergeRight, [ f1, f2 ])
const formatInput = pipe(
  juxt([ reformatAddress, reformatLocation]),
  mergeAll
);

console.log(formatInput(getInput()));


function getInput() { return {address_components:[{long_name:"5",short_name:"5",types:["floor"]},{long_name:"48",short_name:"48",types:["street_number"]},{long_name:"Pirrama Road",short_name:"Pirrama Rd",types:["route"]},{long_name:"Pyrmont",short_name:"Pyrmont",types:["locality","political"]},{long_name:"Council of the City of Sydney",short_name:"Sydney",types:["administrative_area_level_2","political"]},{long_name:"New South Wales",short_name:"NSW",types:["administrative_area_level_1","political"]},{long_name:"Australia",short_name:"AU",types:["country","political"]},{long_name:"2009",short_name:"2009",types:["postal_code"]}],geometry:{location:{lat:-33.866651,lng:151.195827},viewport:{northeast:{lat:-33.8653881697085,lng:151.1969739802915},southwest:{lat:-33.86808613029149,lng:151.1942760197085}}}}; }
<script src="https://cdnjs.cloudflare.com/ajax/libs/ramda/0.26.1/ramda.min.js"></script>
user3297291
  • 22,592
  • 4
  • 29
  • 45
2

This is how to achieve it in plain JS: very few lines of code, the whole magic happens in findObjByType function:

const findObjByType = (obj, type) => 
  obj.address_components.find(o => o.types.includes(type));

const getAddressValues = obj => ({
  latitude: obj.geometry.location.lat,
  longitude: obj.geometry.location.lng,
  city: findObjByType(obj, 'locality').long_name,
  zipCode: findObjByType(obj, 'postal_code').long_name,
  streetName: findObjByType(obj, 'route').long_name,
  streetNumber: findObjByType(obj, 'street_number').long_name
});

Ramda can be helpful, but let's not get carried away with writing obtuse code for the sake of using functional library if plain JavaScript can do the trick in less code that is also easier to read.

EDIT: After reading @user3297291 answer I gotta admit that his Ramda solution is quite elegant, but my points still stands. Never write more code if you can write less while maintaining readability.

The solution on stackblitz

codeepic
  • 3,723
  • 7
  • 36
  • 57
  • 2
    Why not `.find` instead of `.filter()[0]`? – VLAZ Jun 04 '19 at 11:08
  • 2
    I like it. Sometimes I get stuck trying to find solutions with Ramda, when plain JS works best. And sometimes I'm stubborn and want to learn Ramda . I amended my question based on your answer. Do you have a rule of thumb, when Ramda is a good solution, and when plain JS is better? – J. Hesters Jun 04 '19 at 11:48
  • 1
    I am in the same boat - I am somewhat a novice Ramda user. I follow functional programming practices but I find that my brain can come up with solution in plain JS quicker than if I was trying to write it using Ramda from scratch. Once I have it written in JS I assess if it would be cleaner if written in Ramda and proceed accordingly. This is not the most productive use of my time, but it's how I learn more about Ramda. Sometimes after spending some time rewriting code into Ramda, I notice that the end result is not much better than the original JS code. But that's learning. – codeepic Jun 04 '19 at 11:57
  • 2
    @codeepic: I'm almost the reverse. One of the authors of Ramda, I too often look to it first, and then realize later that Ramda is adding nothing to an underlying vanilla version. Ramda was a huge improvement in the pre-ES6 days, but now for many cases, vanilla is just as clean. I still use it -- a lot -- because there are many cases where it *us* cleaner. But I lean on it less. – Scott Sauyet Jun 04 '19 at 12:24
  • 1
    Wait a moment: This approach is not only extremely inefficient because it traverses the array a dozen times, but it is also merely capable of solving the very particular problem at hand. There is zero generalization, little abstraction and little to learn. Sorry, but you totally missed the point of funtional programming. –  Jun 04 '19 at 12:43
  • @bob: Care to post a solution that meets your criteria? I'd be interested to see your approach. – Scott Sauyet Jun 04 '19 at 14:37
  • @ScottSauyet You got me - I don't have an out-of-the-box approach. I'd probably use an optical [fold](http://hackage.haskell.org/package/lens-4.17.1/docs/Control-Lens-Fold.html). But I haven't yet incorporated this into my lib so I am not even sure whether it fits the problem. –  Jun 04 '19 at 14:43
  • @bob - the approach is not `extremely` inefficient, because the code is written to solve this specific case (small input size). I agree that it is not abstract and not general and I do traverse the same array 4 times. I will always choose to write the minimum amount of code that does the job. The moment you need to generalize, you add complexity. I just don't do it unless I have to or when DRYing. Funnily enough the most performant solution, given the large array size, would probably be written in imperative code. – codeepic Jun 04 '19 at 15:11
  • @bob: It wasn't meant as a ding (although perhaps the tone of that comment deserved it!) As well as the answer I posted, I created a version that would be more performant, but which I found less readable. I didn't bother to post it. But I think I will add it to my answer, because there are a great number of factors that determine a good solution to any problem. Here I chose readability over performance, but there are good reasons to go the other way.. – Scott Sauyet Jun 04 '19 at 15:20
  • @bob: This also got me looking at your [scriptum](https://github.com/kongware/scriptum). I'll be curious to see where it goes. – Scott Sauyet Jun 04 '19 at 15:22
  • @ScottSauyet In my comments I try to reflect the tone of the answer. If someone claims: It's that easy, can't you see it, it frequently isn't. Additionally, i believe the FP tag demands a certain degree of lawfulness and proper use of idioms. Anyway, still probably not one of my best comments. Thanks for pointing this out! –  Jun 04 '19 at 15:57
  • I feel like I need to add to the conversation cause @bob is getting some flak. He has very good points, I didn't feel offended by his tone, he just stated the facts. A lot of the functional approach goes over my head and I understand absolutely nothing about monads and more academic terms. I just use the common sense, hence my simple/simplistic solution. – codeepic Jun 04 '19 at 16:26
  • 3
    @codeepic In a sense FP means replacing "common sense"/"making things up" with type directed programming and highly generalized, lawful patterns. It is hard. And after three years of learning (and still considering myself a rookie) I can say: It remains tough. On the other hand it happens more and more often that I find myself reusing the same patterns. I actually reuse code, a lot of it. Sometime I doesn't really know anymore how the underlying algorithm of a composition actually works, because everything is so descriptive and predictable. Umm, I already sound like an FP evangelist... –  Jun 04 '19 at 18:26
2

Function style's strength relies on the guarantee that functions both take a value as input and return a value as output. If a function's output is not completely defined, any consumer of our function's output is subject to potentially undefined behavior. Null-checks are exhausting to write and runtime exceptions are a migraine; we can avoid both by adhering to functional discipline.

The problem presented in your question is a non-trivial one. The data to retrieve is deeply nested and accessing the address components requires an eccentric search and match. To begin writing our transform, we must completely define our function's domain (input) and codomain (output).

The domain is easy: the input data in your question is an object, so our transform must produce a valid result for all objects. The codomain is a bit more specific – since it's possible for our transform to fail in any number of ways, our function will return either a valid result object, or nothing.

As a type signature, here's what that looks like –

type Result =
  { latitude: Number
  , longitude: Number
  , city: String
  , zipCode: String
  , streetName: String
  , streetNumber: String
  }

transform : Object -> Maybe Result

To put that into plain words, given valid input data, our transform will return a valid result, such as –

Just { latitude: 1, longitude: 2, city: "a", zipCode: "b", streetName: "c", streetNumber: "d" }

When given invalid data, our transform will return nothing –

Nothing

No other return value is possible. That means our function guarantees that it will not return a partial or sparse result like –

{ latitude: 1, longitude: 2, city: undefined, zipCode: "b", streetName: "c", streetNumber: undefined }

Functional discipline also says our function should not have side effects, so our transform must also guarantee that it will not throw an Error, such as –

TypeError: cannot read property "location" of undefined
TypeError: data.reduce is not a function

Other answers in this thread do not take such precautions and they throw Errors or produce sparse results when the input data is malformed. Our disciplined approach will avoid these pitfalls, ensuring that any consumer of your transform function will not have to deal with null-checks or have to catch potential runtime errors.

At the core of of your problem, we have deal with many potential values. We'll be reaching for the data.maybe package which provides:

A structure for values that may not be present, or computations that may fail. Maybe(a) explicitly models the effects that implicit in Nullable types, thus has none of the problems associated with using null or undefined — like NullPointerException or TypeError.

Sounds like a good fit. We'll start by sketching some code and waving our hands around in the air. Let's pretend we have a getAddress function which takes a String and an Object and maybe returns a String –

// getAddress : String -> Object -> Maybe String

We begin writing transform ...

const { Just } =
  require ("data.maybe") 

// transform : Object -> Maybe Result
const transform = (data = {}) =>
  getAddress ("locality", data)
    .chain
      ( city =>
          getAddress ("postal_code", data)
            .chain
              ( zipCode =>
                  getAddress ("route", data)
                    .chain
                      ( streetName =>
                          Just ({ city, zipCode, streetName })
                      )
              )
      )

transform (data)
// Just {city: "Pyrmont", zipCode: "2009", streetName: "Pirrama Road"}

transform ({})
// Nothing

Ok, yikes. We're not even done and those nested .chain calls are a complete mess! If you look closely though, there's a simple pattern here. Functional discipline says when you see a pattern, you should abstract; that's a nerd word meaning make a function.

Before we get too deep in .chain hell, let's consider a more generalised approach. I have to find six (6) possible values in a deeply nested object, and if I can get all of them, I want to construct a Result value –

// getAddress : String -> Object -> Maybe String

// getLocation : String -> Object -> Maybe Number

const { lift } =
  require ("ramda")

// make : (Number, Number, String, String, String, String) -> Result
const make = (latitude, longitude, city, zipCode, streetName, streetNumber) =>
  ({ latitude, longitude, city, zipCode, streetName, streetNumber })

// transform : Object -> Maybe Result
const transform = (o = {}) =>
  lift (make)
    ( getLocation ("lat", o)
    , getLocation ("lng", o)
    , getAddress ("locality", o)
    , getAddress ("postal_code", o)
    , getAddress ("route", o)
    , getAddress ("street_number", o)
    )

transform (data)
// Just {latitude: -33.866651, longitude: 151.195827, city: "Pyrmont", zipCode: "2009", streetName: "Pirrama Road", …}

transform ({})
// Nothing

Sanity restored – Above, we write a simple funtion make which accepts six (6) arguments to construct a Result. Using lift, we can apply make in the context of Maybe, sending Maybe values in as arguments. However, if any value is a Nothing, we will get back nothing as a result, and make will not be applied.

Most of the hard work is already done here. We just have to finish implementing getAddress and getLocation. We'll start with getLocation which is the simpler of the two –

// safeProp : String -> Object -> Maybe a

// getLocation : String -> Object -> Maybe Number
const getLocation = (type = "", o = {}) =>
  safeProp ("geometry", o)
    .chain (safeProp ("location"))
    .chain (safeProp (type))

getLocation ("lat", data)
// Just {value: -33.866651}

getLocation ("lng", data)
// Just {value: 151.195827}

getLocation ("foo", data)
// Nothing

We didn't have safeProp before we started, but we keep things easy on ourselves by inventing convenience as we go along. Functional discipline says functions should be simple and do one thing. Functions like this are easier to write, read, test, and maintain. They have the added advantage that they are composeable and are more reusable in other areas of your program. Further yet, when a function has a name, it allows us to encode our intentions more directly - getLocation is a sequence of safeProp lookups - almost no other interpretation of the function is possible.

It might seem annoying that in each part of this answer I reveal another underlying dependency, but this is intentional. We'll keep focused on the big picture, zooming in on smaller pieces only when it becomes necessary. getAddress is considerably more difficult to implement due to the unordered list of components our function must sift through in order to find a specific address component. Don't be surprised if we make up more functions as we go along –

// safeProp : String -> Object -> Maybe a

// safeFind : (a -> Boolean) -> [ a ] -> Maybe a
const { includes } =
  require ("ramda")

// getAddress : String -> Object -> Maybe String
const getAddress = (type = "", o = {}) =>
  safeProp ("address_components", o)
    .chain
      ( safeFind
          ( o =>
              safeProp ("types", o)
                .map (includes (type))
                .getOrElse (false)
          )
      )
    .chain (safeProp ("long_name"))

Sometimes hobbling a bunch of tiny functions together using pipe can be more trouble than it's worth. Sure, a point-free syntax can be achieved, but complex sequences of countless utility functions does little by the way of saying what the program is actually supposed to do. When you read that pipe in 3 months, will you remember what your intentions were?

By contrast, both getLocation and getAddress are simple and straightforward. They're not point-free, but they communicate to the reader what work is supposed to be done. Furthermore, the domain and codomain are defined in total, meaning our transform can be composed with any other program and be guaranteed to work. Ok, let's reveal the remaining dependencies –

const Maybe =
  require ("data.maybe")

const { Nothing, fromNullable } =
  Maybe

const { identity, curryN, find } =
  require ("ramda")

// safeProp : String -> Object -> Maybe a
const safeProp =
  curryN
    ( 2
    , (p = "", o = {}) =>
        Object (o) === o
          ? fromNullable (o[p])
          : Nothing ()
    )

// safeFind : (a -> Boolean) -> [ a ] -> Maybe a
const safeFind =
  curryN
    ( 2
    , (test = identity, xs = []) =>
        fromNullable (find (test, xs))
    )

Above curryN is required because these functions have default arguments. This is a trade-off in favor of a function that provides better self documentation. The more traditional curry can be used if the default arguments are removed.

So let's see our function at work. If the input is valid, we get the expected result –

transform (data) .getOrElse ("invalid input")
// { latitude: -33.866651
// , longitude: 151.195827
// , city: "Pyrmont"
// , zipCode: "2009"
// , streetName: "Pirrama Road"
// , streetNumber: "48"
// }

And because our transform returns a Maybe, we can easily recover when a malformed input is provided –

transform ({ bad: "input" }) .getOrElse ("invalid input")
// "invalid input"

Run this program on repl.it to see the results.

Hopefully the advantages of this approach are evident. Not only do we get a more robust and reliable transform, it was easy to write thanks to high-level abstractions like Maybe and safeProp and safeFind.

And let's think about those big pipe compositions before we part. The reason they sometimes break is because not all functions in the Ramda library are total – some of them return a non-value, undefined. For example, head can potentially return undefined, and the next function down the pipeline will receive undefined as input. Once an undefined infects your pipeline, all safety guarantees are gone. On the flip side, by using a data structure specifically designed to handle nullable values, we remove complexity and at the same time provide guarantees.

Expanding on this concept, we could look for a Decoder library or provide our own. The purpose for this would be reinforce our intentions in a generic module. getLocation and getAddress are custom helpers we used to make transform possible – but more generically, it's a form of decoder, so it helps for us to think of it this way. Additionally, the Decoder data structure could provide better feedback when errors are encountered – ie, instead of Nothing which only signals to us that a value could not be produced, we could attach a reason or other information regarding a specific failure. The decoders npm package is worth looking into.

See Scott's answer to solve this problem another way using high-level abstraction called lens. Note however the function is impure – additional precaution is needed to prevent the function from throwing runtime errors for malformed inputs.


Scott's comment poses a valid scenario where you might want a sparse result. We could redefine our Result type as –

type Result =
  { latitude: Maybe Number
  , longitude: Maybe Number
  , city: String
  , zipCode: String
  , streetName: String
  , streetNumber: String
  }

Of course this mean we would have to redefine transform to build this new structure. Most importantly though, consumers of Result know what to expect as the codomain is well-defined.

Another option would be to keep the original Result type but specify a default value when the latitude or longitude values cannot be found –

const transform = (o = {}) =>
  lift (make)
    ( getLocation ("lat", o)
        .orElse (_ => Just (0))
    , getLocation ("lng", o)
        .orElse (_ => Just (0))
    , getAddress ("locality", o)
    , getAddress ("postal_code", o)
    , getAddress ("route", o)
    , getAddress ("street_number", o)
    )

Every field in Result could be optional, if you so choose. Either way, we must clearly define the domain and codomain and ensure our transform upholds its promise. This is the only way it can be safely incorporated into a larger program.

Mulan
  • 129,518
  • 31
  • 228
  • 259
  • 1
    There's an interesting question hidden in here that can only be determined by the OPs needs. This solution admirably avoids any possibility of raising exceptions, But it also avoids the possibility of partial results. There are times when any partial results (say all fields but `latitude` and `longitude`, or everything but `zipCode`) would just cause problems further downstream, but there are times when they end up being quite useful. Perhaps these could be combined with some sort of traversal over a usable intermediate result of `{city: Just('Pyrmont'), zipCode: Nothing(), ...}` – Scott Sauyet Jun 06 '19 at 13:22
  • Holy mother, that's such an awesome answer. Thank you very much. As to what @ScottSauyet said, my "special need" is that `zipCode` and `streetNumber` can be empty, but `country`, `city`, `streetName`, `longitude` and `latitude` must have a value. – J. Hesters Jun 07 '19 at 20:39
  • 1
    @J.Hesters, the last section using `orElse` is probably the easiest to use in this case. For a more robust solution, I would encourage you to look at the [decoders](https://github.com/nvie/decoders) module - even if you don't end up using it, it can teach you a lot about developing a generalised technique to tackle this problem. – Mulan Jun 07 '19 at 20:55