Grabbing subsets of JS object properties with... GraphQL?

Ben Holmes - Jun 8 '21 - - Dev Community

This entry comes from my web wizardry newsletter, where I explore evergreen solutions to common web dev problems (no matter your favorite framework). If you like what you see, go sign up for free ๐Ÿช„


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:

A side-by-side comparison of 2 JavaScript objects. Under

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
Enter fullscreen mode Exit fullscreen mode

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 verbose pastaIsEdible and panSearedSalmonIsEdible
  • 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,
  }
}
Enter fullscreen mode Exit fullscreen mode

...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 fullscreen mode Exit fullscreen mode

๐Ÿ“ˆ 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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

...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 }
  }
}
*/
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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

  1. loop through all the object keys in whatIWantAsJson, and
  2. 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' }
Enter fullscreen mode Exit fullscreen mode

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
  }
  ...
}
Enter fullscreen mode Exit fullscreen mode

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])
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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,
    },
}
*/
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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,
    },
  ],
}
Enter fullscreen mode Exit fullscreen mode

...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
    }
}
Enter fullscreen mode Exit fullscreen mode

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 {
      ...
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

That's a fair amount of code! To briefly summarize, you'll need to:

  1. Check when our actual data is an array, rather than a simple object
  2. Loop over the actual data and assignValuesToObjectKeys for each one
  3. 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
}
Enter fullscreen mode Exit fullscreen mode

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,
    },
}
*/
Enter fullscreen mode Exit fullscreen mode

Learn a little something?

Glad to hear it! If you want more universal solutions like this, you can sign up for the web wizardry newsletter for some bi-weekly web sorcery ๐Ÿ”ฎ

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .