Messing with JavaScript objects is pretty easy these days. Destructuring syntax is convenient and the spread ...
operator helps with merging objects together. But what about grabbing just... a portion of an object?
This question deserves some visuals. Let's jump into the problem we're trying to solve, and a flexible solution we can add to any existing JavaScript project 💪
What we want
Say I have a big document of structured data. Without manually writing a new object by hand, I just want to pull the little slices that I actually care about.
Here's one such scenario:
In this case, we want a copy of our original fridge, but we only care about those isEdible
subkeys.
My gut reaction is to reach for some declarative tools in my ES6 arsenal. Object destructuring comes to mind at first:
const whatsInMyFridge = {
weekOldPasta: {
...
}
const { weekOldPasta: { isEdible: pastaIsEdible },
panSearedSalmon: { isEdible: panSearedSalmonIsEdible }
} = whatsInMyFridge
There's a few issues with this:
- We can't easily destructure keys of the same name. Notice I had to convert each
isEdible
variable to the verbosepastaIsEdible
andpanSearedSalmonIsEdible
- Destructuring leads to some pretty gnarly code as it gets more complex. Just with a few keys, we're already hitting multi-line { curly hell }.
And most of all, we still have to build our new object at the end! Our destructuring statement actually just made some one-off variables for each key in the object. We'll still have to do this:
const whatsEdible = {
weekOldPasta: {
isEdible: pastaIsEdible,
},
panSearedSalmon: {
isEdible: panSearedSalmonIsEdible,
}
}
...which is hardly better than just writing the object from scratch 😢
What we really want is some magical syntax for just the keys we want to retrieve. Something like this really:
whatsInMyFridge.giveMeTheseKeys({
weekOldPasta {
isEdible
},
panSearedSalmon {
isEdible
}
}) // -> a beautiful formatted JS object
📈 Enter: GraphQL
If you've worked with GraphQL before, you probably noticed how close that example comes to a GraphQL query!
A brief rundown for those unfamiliar: GraphQL is a "querying" language originally built for API calls. It was mainly born from the frustrations with REST requests, as API endpoints had to predict all the data a client might want to retrieve.
GitHub recently migrated to GraphQL because of this. Imagine this scenario:
- User A wants to get information about their GitHub profile. They want to send off a username and get back the accounts name and profile picture
- User B also wants some GitHub profile info. However, they're looking for a different set of info: the list of user recovery emails and their personal bio.
As you could imagine, User C might want a new combination of fields, as might users D-Z. So instead of returning a massive JSON payload to satisfy everyone, GitHub exposed a GraphQL API for you to describe exactly which fields you want.
Here's how User A might request a name and profile picture as part of their request body This is from demo purposes, and won't actually work if you send to GitHub
{
userProfile(email: '10xengineer@genius.club') {
name
picture {
src
alt
}
}
}
...And GitHub will "fill in the blanks" by providing values to those requested keys. As you can imagine, this syntax is flexible enough to use on any blob of JSON you want to filter down 👀
📖 Applying GraphQL to reading JSON objects
💡 TLDR: If you want the final solution without all the walkthrough, jump down to the finished product!
Let's figure out how to use that fancy syntax for our use case. The biggest question to resolve is "how do we interpret a GraphQL query in JS land?" Sadly, there isn't a nice "plain JS" solution, so we will be reaching for a library here.
Go ahead and install this graphql-query-to-json package. It does have a fair amount of sub-dependencies like the core graphql package and the complimentary json-to-graphql-query, so if that bothers you... my apologies 😢
Let's see what we get from our old "what's edible in my fridge" request:
const { graphQlQueryToJson } = require("graphql-query-to-json")
// or if you prefer: import { graphQlQueryToJson } from 'graphql-query-to-json'
const asJson = graphQlQueryToJson(`{
weekOldPasta {
isEdible
}
panSearedSalmon {
isEdible
}
}`)
console.log(asJson)
/* 👇
{
query: {
weekOldPasta: { isEdible: true },
panSearedSalmon: { isEdible: true }
}
}
*/
Neat! Toss in a string, get back a JS object. You'll notice that it wraps our requested object with the query
key. This would be useful if we were sending this request to an API, but for our purposes, we'll just ignore that key in our helper function. It also stubs out any unknown key values with true
, which we'll use to track down unfilled values later 👀
Traversing our query
With this JS object in hand, it's time to walk through all the keys and figure out which values to fill in. Let's start with a simple example that only goes 1 level of keys deep:
const myFridge = {
numEggs: 5,
pintsOfIceCream: 3,
degreeUnits: 'celsius',
}
const whatIWant = `{
numEggs
degreeUnits
}`
// grab the ".query" straight away, since we won't need that nested key
const whatIWantAsJson = graphQlQueryToJson(whatIWant).query
// 👉 { numEggs: true, degreeUnits: true }
Now we have our set of keys (numEggs
and degreeUnits
) each with a value of true
. To assign our actual values in place of those true
flags, we can
- loop through all the object keys in
whatIWantAsJson
, and - assign values from the same key in
myFridge
.
// loop over the object keys...
for (const key of Object.keys(whatIWantAsJson)) {
// and if that key has a value of "true"...
if (whatIWantAsJson[key] === true) {
// replace "true" with the value from myFridge
whatIWantAsJson[key] = myFridge[key]
}
}
console.log(whatIWantAsJson)
// 👉 { numEggs: 5, degreeUnits: 'celsius' }
Handling nested objects
This basic loop handles 1 level of nesting. But what if we have a request like this?
{
// level 1
weekOldPasta {
// level 2
isEdible
}
...
}
For this, we'll need a way to run our loop over Object.keys
for every level of keys in our object. Get ready to put on your computer science hat, because we're using recursion 😨
Pay attention to this new else
statement we're adding:
// wrap our loop in a function we can call
function assignValuesToObjectKeys(whatIWant, myFridge) {
for (const key of Object.keys(whatIWant)) {
if (whatIWant[key] === true) {
whatIWant[key] = myFridge[key]
} else {
// if the value isn't "true", we must have found a nested object
// so, we'll call this same function again, now starting from
// the nested object inside whatIWant
assignValuesToObjectKeys(whatIWant[key], myFridge[key])
}
}
}
This is a classic example of a recursive function. We have 2 clauses here:
- The base case: When we hit a value of
true
, we stop looking for nested objects - The recursive function call: When we haven't hit the "base" of our nested object, keep drilling down the chain of nested keys using the same function
With this in place, we have a reusable JS function for anywhere in our codebase 🥳
const myFridge = {
weekOldPasta: {
noodleSogginess: “high”,
numMeatballs: 4,
isEdible: false,
},
panSearedSalmon: {
oilUsed: “avocado”,
numSpices: 3,
isEdible: true,
}
}
const whatIWant = graphQlQueryToJson(`{
weekOldPasta {
isEdible
}
panSearedSalmon {
isEdible
}
}`).query
assignValuesToObjectKeys(whatIWant, myFridge)
console.log(whatIWant)
/* 👉 {
weekOldPasta: {
isEdible: false,
},
panSearedSalmon: {
isEdible: true,
},
}
*/
Cleaning this up a little
You'll notice that our assignValuesToObjectKeys
function doesn't return anything; it just modifies the whatIWant
object we passed in. For added readability, we might add a wrapper function to handle the graphQlQueryToJson
call and actually return
our requested object:
function grabPartialObject(originalObject = {}, query = "") {
const whatIWant = graphQlQueryToJson(query).query
assignValuesToObjectKeys(whatIWant, originalObject)
return whatIWant
}
...
const whatsEdible = grabPartialObject(myFridge, `{
weekOldPasta {
isEdible
}
panSearedSalmon {
isEdible
}
}`)
console.log(whatsEdible) // gives us the same object as before!
Handling arrays
So we've conquered nested objects. But what if we have an array of objects that we want to filter?
For instance, say our fridge data was structured just a bit differently:
const myFridge = {
food: [
{
name: 'Week old pasta',
noodleSogginess: 'high',
numMeatballs: 4,
isEdible: false,
},
{
name: 'Pan Seared Salmon',
oilUsed: 'avocado',
numSpices: 3,
isEdible: true,
},
],
}
...and we just care about the name
and isEdible
keys for every object in that array. Following how GraphQL requests normally work, we'd expect this sort of syntax to work:
{
food {
name
isEdible
}
}
In other words, treat food
like it's a regular object in the request, and we'll be smart enough to handle arrays of data.
This answer is a bit more involved than our previous examples. So, I'll leave you with a thoroughly commented codeblock:
function assignValuesToObjectKeys(whatIWant, myFridge) {
for (const key of Object.keys(whatIWant)) {
if (whatIWant[key] === true) {
// 👇 If the fridge data happens to be an array...
} else if (Array.isArray(myFridge[key])) {
// first, keep track of the object they requested
const originalRequest = whatIWant[key]
// then, create an array where that request used to be
// for us to "push" new elements onto
whatIWant[key] = []
// loop over the items in our array of food...
for (const fridgeItem of myFridge[key]) {
// make a variable to store the result of assignValuesToObjectKeys
// we use { ...originalRequest } here to create a "copy"
const requestedItem = { ...originalRequest }
// grab the keys we want out of that array element
assignValuesToObjectKeys(requestedItem, fridgeItem)
// then, push our shiny new object onto our running list
whatIWant[key].push(requestedItem)
}
} else {
// ...
}
}
}
That's a fair amount of code! To briefly summarize, you'll need to:
- Check when our actual data is an array, rather than a simple object
- Loop over the actual data and
assignValuesToObjectKeys
for each one - Push the results onto a running array in
whatIWant
, with necessary helper variables to keep track of your original request
🚀 The finished product
Here's how our finished product looks! I've renamed myFridge
👉 actualObj
and whatIWant
👉 requestedObj
so our naming conventions are more universal. I also added a hasOwnProperty
check to assert we're requesting a key that actually exists. If not, raise an exception.
Remember, you'll need to add the graphql-query-to-json package package to your project for this to work.
const { graphQlQueryToJson } = require("graphql-query-to-json")
function assignValuesToObjectKeys(requestedObj, actualObj) {
for (const key of Object.keys(requestedObj)) {
if (!actualObj.hasOwnProperty(key)) {
throw `You requested a key that doesn't exist: ${key}`
} else if (requestedObj[key] === true) {
requestedObj[key] = actualObj[key]
} else if (Array.isArray(actualObj[key])) {
// keep track of the object they requested
const originalRequest = requestedObj[key]
// then, create an array where that request used to be
// for us to "push" new elements onto
requestedObj[key] = []
for (const actualItem of actualObj[key]) {
// make a variable to store the result of assignValuesToObjectKeys
// we use { ...originalRequest } here to create a "copy"
const requestedItem = { ...originalRequest }
assignValuesToObjectKeys(requestedItem, actualItem)
requestedObj[key].push(requestedItem)
}
} else {
console.log(requestedObj[key])
// if the value isn't "true", we must have found a nested object
// so, we'll call this same function again, now starting from
// the nested object inside requestedObj
assignValuesToObjectKeys(requestedObj[key], actualObj[key])
}
}
}
// 👇 Function you'll actually `export` for others to use
function grabPartialObject(actualObj = {}, query = '') {
const requestedObj = graphQlQueryToJson(query).query
assignValuesToObjectKeys(requestedObj, actualObj)
return requestedObj
}
Usage example
const { grabPartialObject } = require('./some/helper/file')
const myFridge = {
weekOldPasta: {
noodleSogginess: “high”,
numMeatballs: 4,
isEdible: false,
},
panSearedSalmon: {
oilUsed: “avocado”,
numSpices: 3,
isEdible: true,
}
}
const whatsEdible = grabPartialObject(myFridge, `{
weekOldPasta {
isEdible
}
panSearedSalmon {
isEdible
}
}`)
console.log(whatsEdible)
/* 👉 {
weekOldPasta: {
isEdible: false,
},
panSearedSalmon: {
isEdible: true,
},
}
*/