Ramda: Fold an object

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/[email protected]"></script>
<script src="https://bundle.run/[email protected]"></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.)


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>


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