How to dynamically retrieve arbitrarily specified, deeply nested values from a JavaScript object?

Reposting as a corrected version of this question. I have reviewed this, but it didn’t help me deal with unknown array nestings.

EDIT: Added information on why Lodash _.get won’t work. Here’s a Stackblitz. There’s an npm library, Object-Scan that would work — but, I can’t get Angular to play nice with that package.

This is for a column renderer component in an Angular app. The user selects a column to display, and the column is mapped to whatever it is. So, in the below example, if user selects to display “Categories”, and it is mapped to “interests[categories]”, the function then needs to go find values that correspond to that mapping in the data.

Sample data:

const response = {
  id: "1234",
  version: "0.1",
  drinks: ["Scotch"],
  interests: [
    {
      categories: ["baseball", "football"],
      refreshments: {
        drinks: ["beer", "soft drink"]
      }
    },
    {
      categories: ["movies", "books"],
      refreshments: {
        drinks: ["coffee", "tea", "soft drink"]
      }
    }
  ],
  goals: [
    {
      maxCalories: {
        drinks: "350",
        pizza: "700"
      }
    }
  ]
};

For this data, the possible mappings are: id, version, drinks, interests[categories], interests[refreshments][drinks], goals[maxCalories][drinks], goals[maxCalories][pizza],

Requirement: I need a recursive function that will work in Angular, and accept two args: one for the data object response, and a second string of the selector mapping that will return the applicable, potentially nested values, iterating through any objects and/or arrays that are encountered. The mapping string may have either dotted or bracketed notation. Readable, self-documenting code is highly desired.

For example, based on the above data object:

  • getValues("id", response); should return ["1234"]
  • getValues("version", response); should return ["0.1"]
  • getValues("drinks", response); should return ["Scotch"]
  • getValues("interests.categories", response) should return ["baseball", "football", "movies", "books"]
  • getValues("interests[refreshments][drinks]", response); should return ["beer", "soft drink", "coffee", "tea", "soft drink" ], accounting for however many items may be in the interests array.
  • getValues("goals.maxCalories.drinks", response); should return ["350"]

Returned values will ultimately be deduped and sorted.

Original function:

function getValues(mapping, row) {
  let rtnValue = mapping
    .replace(/]/g, "")
    .split("[")
    .map(item => item.split("."))  
    .reduce((arr, next) => [...arr, ...next], [])
    .reduce((obj, key) => obj && obj[key], row)
    .sort();

  rtnValue = [...new Set(rtnValue)]; //dedupe the return values

  return rtnValue.join(", ");
}

The above works fine, just like Lodash’ get: if you pass, for example: "interests[0][refreshments][drinks], it works perfectly. But, the mapping is not aware of nested arrays and passes in simply "interests[refreshments][drinks]", which results in undefined. Any subsequent array items have to be accounted for.

Answer

Update

After I gave the answer below, I had some dinner. Food seemed to make me realize how ridiculous I was being. My response to the earlier question was clearly coloring how I thought about this one. But there’s a much, much simpler approach, similar to how I would write a path function, with only a small amount of additional complexity to handle the mapping of arrays. Here’s a cleaner approach:

// main function
const _getValues = ([p, ...ps]) => (obj) =>
  p == undefined 
    ? Array.isArray(obj) ? obj : [obj]
  : Array .isArray (obj)
    ?  obj .flatMap (o => _getValues (ps) (o[p] || []))
  : _getValues (ps) (obj [p] || [])


// public function
const getValues = (query, obj) =>
  _getValues (query .split (/[[].]+/g) .filter (Boolean)) (obj)


// sample data
const response = {id: "1234", version: "0.1", drinks: ["Scotch"], interests: [{categories: ["baseball", "football"], refreshments: {drinks: ["beer", "soft drink"]}}, {categories: ["movies", "books"], refreshments: {drinks: ["coffee", "tea", "soft drink"]}}], goals: [{maxCalories: {drinks: "350", pizza: "700"}}]};


// demo
[
  'id',                               //=> ['1234']
  'version',                          //=> ['0.1']
  'drinks',                           //=> ['Scotch']
  'interests[categories]',            //=> ['baseball', 'football', 'movies', 'books']
  'interests[refreshments][drinks]',  //=> ['beer', 'soft drink', 'coffee', 'tea', 'soft drink']
  'goals.maxCalories.drinks',         //=> ['350']
  'goals.maxCalories.notFound',       //=> []
  'foo.bar.baz',                      //=> []
  'interests[categories][drinks]',    //=> []
] .forEach (
  name => console.log(`"${name}" --> ${JSON.stringify(getValues(name, response))}`)
)
.as-console-wrapper {max-height: 100% !important; top: 0}

There’s not much to say about it. It is a simple enough recursion, still with a separate wrapper function to convert your string input format into the arrays I find more natural to use for such things. (Because this function was so simple, I inlined the name2path function I had used in previous versions.

Moral

There is a strong moral in the mistaken version below: It is far too tempting to take a change in requirements as instructions to change our code. We always should try to look at the new requirements also as a time to rethink our approach, and possibly rewrite our code. It’s worth doing so when it leads to a simpler system. Here it clearly did.

Initial Answer

(This should be considered entirely superseded by the update above. I’m keeping it here to show the folly of forgetting to think about a problem from the beginning when you have new requirements. The approach above is so much cleaner that this version now looks silly.)

I don’t have a lot of time now to describe this technique. Much of what I said in response to the previous question still holds for this code. getPaths is enhanced a bit to return numeric nodes for array indices.

The main function, getValues first gets all paths in the current object (with values such as ["interests", 0, "categories", 1].) It also converts your query into a key, by joining all the values with '~'. Then we filter the paths to find which ones match the key when we remove the numeric values. Simultaneously, we remove those that end with an number, since their parents will give us all the values we need. Then we flatMap calls to path to retrieve the value at each of these paths in the original object.

The earlier answer misinterpreted your requirements fairly severely, but made for an interesting function anyway. I think this is closer to what you really need.

// utility functions
const isNumber = (n) =>
  Number(n) === n

const path = (ps) => (obj) =>
  ps .reduce ((o, p) => (o || {}) [p], obj)

const last = (xs) =>
  xs [xs .length - 1]
  
const getPaths = (obj, arr = Array .isArray (obj)) =>
  typeof obj == 'object' 
    ? Object .entries (obj) 
        .flatMap (([k, v]) => [
          [arr ? Number (k): k], 
          ...getPaths (v) .map (p => [arr ? Number (k): k, ...p])
        ])
    : []


// helper function
const name2path = (name) => // probably not a full solutions, but ok for now
  name .split (/[[].]+/g) .filter (Boolean)


// main function
const getValues = (query, obj, key = name2path (query) .join ('~')) => 
  getPaths (obj)
    .filter (
      p => p.filter(n => ! isNumber (n)) .join ('~') == key && ! isNumber (last (p))
    )
   .flatMap (p => path (p) (obj))


// sample data
const response = {id: "1234", version: "0.1", drinks: ["Scotch"], interests: [{categories: ["baseball", "football"], refreshments: {drinks: ["beer", "soft drink"]}}, {categories: ["movies", "books"], refreshments: {drinks: ["coffee", "tea", "soft drink"]}}], goals: [{maxCalories: {drinks: "350", pizza: "700"}}]};


// demo
[
  'id',
  'version',
  'drinks',
  'interests[categories]',
  'interests[refreshments][drinks]',
  'goals.maxCalories.drinks',
] .forEach (
  name => console.log(`"${name}" --> ${JSON.stringify(getValues(name, response))}`)
)

A limitation of this method is that the query argument cannot include numeric indices. That means you cannot search only the first instance of some array. While I’m sure we could change this to allow that, I’m guessing the code would be significantly more complex than what we have here.

Leave a Reply

Your email address will not be published. Required fields are marked *