Testing Shallow Object Properties
Our destinations are Booleans – we reach them or we don’t – but our journeys are spectrums, because there are so many paths we can take to our destination that make getting there that much better.
— A.J. Darkholme
In this chapter, we'll make logical decisions in our code (if
/else
) by
testing our datasets' properties with a few boolean-returning helper functions.
Outline:
In case you missed it, we'll be using some ISS and astronaut data as our datasets.
ISS' Current Location
{
"message": "success",
"timestamp": 1617930803,
"iss_position": {
"latitude": "27.7270",
"longitude": "133.2581"
}
}
"How Many People Are In Space Right Now?"
{
"message": "success",
"number": 7,
"people": [
{
"craft": "ISS",
"name": "Sergey Ryzhikov"
},
{
"craft": "ISS",
"name": "Kate Rubins"
},
{
"craft": "ISS",
"name": "Sergey Kud-Sverchkov"
},
{
"craft": "ISS",
"name": "Mike Hopkins"
},
{
"craft": "ISS",
"name": "Victor Glover"
},
{
"craft": "ISS",
"name": "Shannon Walker"
},
{
"craft": "ISS",
"name": "Soichi Noguchi"
}
]
}
We will assume we're storing those objects as variables named iss
and
astros
.
has
We're working one morning, eating scones and refactoring some code, when a fellow developer, who lives in Iceland and started work a few hours before we did today, pings us with the following message:
Hjálp!
We shipped some astronaut code this morning that puts a method,
toString
, on someastros
objects, and it joins all the astronauts' names together with rocket ships like this:"Sergey Ryzhikov 🚀 Kate Rubins 🚀 ..."
Pretty cool, right?
We only want to use this method on the
astros
objects where it's defined, but we forgot thattoString
is already a defined method defined on anObject
instance, so someastros
objects are calling that method and returning"[object Object]"
when we want them to do something else!Can you help us? Takk!
Our Icelandic coworker then sends us the code:
const astrosWithToString = {
"message": "success",
"number": 7,
"people": [/* omitted for brevity */],
toString() {
return astrosPeopleWithRockets(this)
}
}
const astrosWithoutToString = {
"message": "success",
"number": 7,
"people": [/* omitted for brevity */],
}
const astrosToString = data => {
if ('toString' in data) { // THIS IS WHERE THE BUG HAPPENS!
return data.toString()
}
return `There are ${data.number} astronauts`
}
// astrosToString(astrosWithToString) // this works
astrosToString(astrosWithoutToString) // this doesn't!
View this buggy astrosToString
code in the Ramda
REPL.
The astrosPeopleWithRockets
code comes from the prior lesson on "Reading
Shallow Object Properties", so check
that out to see how we arrived at the nifty little helper functions you'll find
in the REPL linked above.
Aha! We see where the misunderstanding happened. The 'toString' in data
code
is checking that there is a property defined on the object called toString
—
whether or not it inherited that property! All object instances have a
.toString()
method that they inherit, so it'll always have toString
defined
no matter what. What we want is to check if toString
was explicitly defined by
us on the astros
object.
We first confirm our assumption and fix the bug by making this change:
// before
if ('toString' in data) {/*...*/}
// after
if (Object.prototype.hasOwnProperty.call(data, 'toString')) {/*...*/}
The hasOwnProperty
exists on all objects, but it could be overwritten like
toString
was, so we use an external hasOwnProperty
to do our check.
This check seems rather lengthy, so let's convert that to a function named
hasProp
:
const hasProp = (prop, obj) =>
Object.prototype.hasOwnProperty.call(obj, prop)
Surprise! Ramda already has a helper for this,
has
, so we can replace our hasProp
with
has
.
We then write a message back to our Icelandic colleague:
Hæ!
We found the issue, and it's a matter of testing whether we defined the object property or not. Here you go!
Eigðu góðan dag!
import { has } from 'ramda'
// ...
const astrosToString = data => {
if (has('toString', data)) {
return data.toString()
}
return `There are ${data.number} astronauts`
}
View the updated astrosToString
and hasProp
functions in the Ramda
REPL.
propEq
One fine afternoon, our error monitoring service lets us know that our code is throwing errors when trying to access the latitude property from the ISS' location API response.
We quickly realize that if there is a problem with the API, we won't get the
data back, so iss.iss_position.latitude
won't work, for iss_position
is
undefined
in the response:
{
"message": "error",
"timestamp": 1617930803
}
There are many ways to handle this error safely, but we're going to address it
by simply checking if the message
property is "success"
or not:
if (iss.message === 'success') {
// carry on...
}
Great! Call it a day!
...But our solution nags at us. We are accessing a property's value and equating it with an expected value. What if we made this a function?
const isMessageSuccess = data =>
(data || {}).message === 'success' // or `data?.message === 'success'`
Note: the Ramda REPL doesn't currently support optional chaining like
data?.message === 'success'
.
Not bad, but we've simply moved the operations to a single place. What if we wrote a function that looked up any property on an object and then compared it with another value?
const doesPropEq = (key, val, data) =>
(data || {})[key] === val // or `data?.[key] === val`
Nice! Let's try it out:
const isMessageSuccess = data =>
doesPropEq('message', 'success', data)
isMessageSuccess(iss) // true
View doesPropEq
and isMessageSuccess
in the ramda
REPL.
Now that we understand our need for doesPropEq
, we can swap that out with
ramda's propEq
.
import { propEq } from 'ramda'
// ...
const isMessageSuccess = data =>
propEq('message', 'success', data)
isMessageSuccess(iss) // true
Stopping the isMessageSuccess
implementation work at this point is totally
acceptable, but we can take it a little further.
Since all functions in ramda are auto-curried, that means that we can refactor
isMessageSuccess
like this:
// Step 0
const isMessageSuccess = data =>
propEq('message', 'success', data)
// Step 1
const isMessageSuccess = data =>
propEq('message', 'success')(data)
// Step 2
const isMessageSuccess =
propEq('message', 'success')
// Step 3
// isMessageSuccess :: ISSData -> Bool
const isMessageSuccess =
propEq('message', 'success')
-
In Step 1, we demonstrate that
propEq
will accept our last argument as a separate function call (the result of callingpropEq
the first time will wait until it has all the arguments). -
In Step 2, we realize that accepting an argument and passing it on again is redundant, and so we can remove the need for a function closure and instead let the result of calling
propEq
with the first two values be what is bound toisMessageSuccess
. -
In Step 3. we acknowledge that implicitly forwarding a function argument comes at the cost of remembering, "What am I passing in, again?" If you don't have a type-checker , you can provide some pseudo-types (these ones are in a Haskell style) where the data is defined in order to explain what
ISSData
is:// ISSData = { message :: Message // , timestamp :: UnixTimestamp // , iss_position :: LatLong // } // // Message = 'success' | 'error' // // UnixTimeStamp = Number // // LatLong = { latitude :: String // , longitude :: String // }
The whole point of Step 3 is simply to identify what the expected input and output types are so that someone else (or you in 3 months) can easily understand a terse function at a glance.
Check out this propEq
usage plus these pseudo-types in the ramda
REPL.
propIs
TODO
propSatisfies
TODO