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.