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'),
});
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.)
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.
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 Maybe
s 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 Just
s.)
Not that much of an improvement maybe, but I have some suggestions:
indexBy
instead of the (kind of hard to read) inline reduce function.juxt
and mergeAll
)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
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With