Skip to content

Instantly share code, notes, and snippets.

@drwpow
Last active January 6, 2020 05:23
Show Gist options
  • Save drwpow/37d65f87501d05af4e1372511e0d32e4 to your computer and use it in GitHub Desktop.
Save drwpow/37d65f87501d05af4e1372511e0d32e4 to your computer and use it in GitHub Desktop.
Limiting circular JS object depth
/**
* Limit depth of any object by key name (ex: limitDepth(myObj, {foo: 3, bar: 5}) limits “foo” to 3 nested occurrences and “bar” to 5 nested occurrences)
* @param {any} obj
* @param {{[index:string]:number}} depth
*/
function limitDepth(obj, depth) {
const count = {};
return JSON.parse(
JSON.stringify(obj, (name, value) => {
if (typeof count[name] !== 'number') {
count[name] = 0;
}
count[name] += 1;
const max = depth[name] >= 0 ? depth[name] : Infinity;
if (count[name] <= max) {
if (typeof value === 'object') {
return Array.isArray(value) ? [...value] : { ...value };
}
return value;
}
return undefined;
})
);
}
@drwpow
Copy link
Author

drwpow commented Jan 6, 2020

Limiting circular JS object depth

Function that allows you to take a circular JS object, and limit the depth based on the number of times a key appears. Useful when you want to expose a circular JS object via an API, or when saving a circular object to a NoSQL database.

Example

// create a circular object
const director = { firstName: "Hayao", lastName: "Miyazaki" };
const film = { title: "Castle in the Sky" };

film.director = director;
director.films = [film];

// limit by 3 "director"s or 5 "films", whichever comes first
const depth = { director: 3, films: 5 };
const limitedDirector = limitDepth(director, depth);
console.log(limitedDirector);
{
  firstName: "Hayao",
  lastName: "Miyazaki",
  films: [
    {
      title: "Castle in the Sky",
      director: {
        firstName: "Hayao",
        lastName: "Miyazaki",
        films: [
          {
            title: "Castle in the Sky",
            director: {
              firstName: "Hayao",
              lastName: "Miyazaki",
              films: [
                {
                  title: "Castle in the Sky",
                  director: {
                    firstName: "Hayao",
                    lastName: "Miyazaki",
                    films: [
                      {
                        title: "Castle in the Sky"
                        // director is hidden because we’re at the max depth (3rd occurrence of "director")
                      }
                    ]
                  }
                }
              ]
            }
          }
        ]
      }
    }
  ]
}

Drawbacks

There are 2 drawbacks of this approach.

The first is this assumes unique keys in your JSON structure. If you have to deal with duplicate keys, you may need to extend this function to suit your needs.

The second is you must manually specify all nested object keys. It’ll throw an error if some part of the structure is recursively nested and you don’t specify the max depth you’d like to reach.

Additional explanation (optional)

Read on only if you’d like to learn more about how this works.

What is a “circular structure?”

Let’s create 2 objects like so, and reference each from the other:

const director = { firstName: "Hayao", lastName: "Miyazaki" };
const film = { title: "Castle in the Sky" };

film.director = director;
director.films = [film];

Let’s inspect the director object we just made with console.log(director);:

{
  firstName: "Hayao",
  lastName: "Miyazaki",
  films: [
    {
      title: "Castle in the Sky",
      director: {
        firstName: "Hayao",
        lastName: "Miyazaki",
        films: [
          {
            title: "Castle in the Sky",
            director: {
              firstName: "Hayao",
              lastName: "Miyazaki",
              films: [...] // recurses infinitely
          }
        ]
      }
    }
  ]
}

You get the idea—it just repeats and repeats and repeats and repeats…

This works if we stay within JavaScript because JavaScript (thankfully) is just re-using memory addresses at no additional cost. But say we wanted to serve this object via an API, or store it in a database. Let’s see what happens when we try JSON.stringify(director):

TypeError: cyclic object value

And here we have a circular structure—an object that references another object that references itself. It has a myriad of uses, but very tough to expose via an API.

JSON.stringify()’s replacer to the rescue!

Searching Stack Overflow you may find that there are some large, complicated functions you could write to manage this. But arguably the simplest way to handle it is leveraging JSON.stringify—the very thing that’s giving us trouble! More specifically, we can use the little-used 2nd “replacer” parameter in JSON.stringify(). Let’s start with the following:

JSON.stringify(director, (key, value) => {
  return value;
});

We’re returning return value at the end no matter what. This will cause an infinite loop. But what if, in certain conditions, we return undefined instead?

let n = 0;
JSON.parse(
 JSON.stringify(director, (key, value) => {
    n++;
    if (n <= 10) { // arbitrarily stop after 10 steps
      if (typeof value === "object") {
        return Array.isArray(value) ? [...value] : { ...value }; // for arrays and objects, we need to duplicate the value to prevent more cyclic errors
      }
      return value;
    }
    return undefined;
  })
);

Note: we also wrapped it in JSON.parse() because in the end we want an object, not a string

{ // 1
  firstName: "Hayao", // 2
  lastName: "Miyazaki", // 3
  films: [ // 4
    { // 5
      title: "Castle in the Sky", // 6
      director: { // 7
        firstName: "Hayao" // 8
        lastName: "Miyazaki", // 9
        films: [null] // 10
      }
    }
  ]
}

After the 10th iteration, we return undefined and stop parsing there. While this fixes our cyclic object error, this is a bad approach. Objects don’t have numeric indexes, so we can’t predict exactly where to stop!. We need an approach that pays closer attention to the keys being used.

Limiting depth by object key

To maintain control over depth and the nesting of films and director, let’s keep a track of how many times each appears:

var filmsCount = 0;
var directorCount = 0;
JSON.parse(
  JSON.stringify(director, (key, value) => {
    if (key === "films") {
      filmsCount++;
    }
    if (key === "director") {
      directorCount++;
    }
    if (filmsCount <= 3 && directorCount <= 3) {
      if (typeof value === "object") {
        return Array.isArray(value) ? [...value] : { ...value };
      }
      return value;
    }
    return undefined;
  })
);
{
  firstName: "Hayao",
  lastName: "Miyazaki",
  films: [
    {
      title: "Castle in the Sky",
      director: {
        firstName: "Hayao",
        lastName: "Miyazaki",
        films: [
          {
            title: "Castle in the Sky",
            director: {
              firstName: "Hayao",
              lastName: "Miyazaki",
              films: [
                {
                  title: "Castle in the Sky"
                  director: {
                    firstName: "Hayao",
                    lastName: "Miyazaki"
                    // films is hidden because we’re at max depth (3rd occurrence of "films")
                  }
                }
              ]
            }
          }
        ]
      }
    }
  ]
}

In this example, we got to the third films and third director occurrence before it stopped executing! This is the basic premise behind the function.

Making it configurable

The only major difference between the above function and the final one is the mapping. In the function, we keep track of how many times each key appears with count:

function limitDepth(obj, depth) {
  const count = {};

  return JSON.parse(
    JSON.stringify(obj, (name, value) => {
      if (typeof count[name] !== 'number') {
        count[name] = 0;
      }
      count[name] += 1;

If at any point we inspected the value of count we’d find something like:

{
  "": 1,
  "0": 3,
  "firstName": 4,
  "lastName": 4,
  "films": 3,
  "title": 3,
  "director": 3
}

Every layer we keep a running tally of the number of times we’ve run across a particular index. See the "" key? We hit that once at the very beginning, because the object itself didn’t have a key (if that was bothersome we could add logic in the function to take care of that). We also hit a "0" key whenever we entered the films array.

With this object, we can let the consumer of the function specify how many times they’d like to visit each key with the depth param.

That’s all there is to it! From here, feel free to expand on this function as needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment