1

I have a tree which will which vary in the amount of nested objects it may contain. A generalized example looks like -

const treeData = {
    id: 1,
    title: "Group1",
    tagClass: "Object",
    children: [
        {
            id: 2,
            title: "Group2",
            tagClass: "Object",
            children: [
                { id: 3, title: "Tag1", tagClass: "Variable" },
                { id: 4, title: "Tag2", tagClass: "Variable" },
            ],
        },
        { id: 5, title: "Group3", tagClass: "Object" },
        { id: 6, title: "Tag3", tagClass: "Variable" },
    ],
};

I'm trying to remove all nested objects which have tagClass: Variable as a property, and then leave a reference of that object's ID in an array as an attribute in its parent object -

const treeData = {
    id: 1,
    title: "Group1",
    tagClass: "Object",
    tagIds: [6]
    children: [
        {
            id: 2,
            title: "Group2",
            tagClass: "Object",
            tagIds: [3,4]
        },
        { id: 5, title: "Group3", tagClass: "Object" },
    ],
};

I know that .filter, .map, and recursion will be handy tools for this but I am coming up short and nearing wits end. Any help to solve this algorithmic problem is very much appreciated. Part of what I've tried -

const recursiveFunc = (treeData) => {
    if (treeData.children) {
        treeData.children = treeData.children
            .filter((child) => child.tagClass === "Object")
            .map((child) => recursiveFunc(child));
        return treeData;
    }
};

const updatedTreeData = recursiveFunc(treeData);

Thank you to the intelligent mind who can help to solve this. Cheers.

2
  • 1
    "...and then leave a reference of that object's ID in an array as an attribute in its parent object." Huh? Commented Apr 19, 2020 at 23:56
  • 4
    @T.J.Crowder I guess he meant tagIds: [3,4] from the second code snippet Commented Apr 19, 2020 at 23:59

4 Answers 4

2

recursiveFunc method based on your idea. create tagIds property during filter. Please see comment in code snippet for details.

const treeData = {
    id: 1,
    title: "Group1",
    tagClass: "Object",
    children: [
        {
            id: 2,
            title: "Group2",
            tagClass: "Object",
            children: [
                { id: 3, title: "Tag1", tagClass: "Variable" },
                { id: 4, title: "Tag2", tagClass: "Variable" },
            ],
        },
        { id: 5, title: "Group3", tagClass: "Object" },
        { id: 6, title: "Tag3", tagClass: "Variable" },
    ],
};

const recursiveFunc = (treeData) => {
  if(treeData.children){
     //filter treeData.children
     const children = treeData.children.filter(child => {
      if(child.tagClass === 'Variable'){
        //if tagclass is variable we create tagIds property for treeData
        treeData.tagIds? treeData.tagIds.push(child.id) : treeData.tagIds = [child.id];
        // return false to filter out this child
        return false
      }
      //not varaible tagclass, we go deeper
      recursiveFunc(child);
      //keep the child
      return true
    })
    //if children is an empty array, delete children property from treeData
    children.length === 0 ? delete treeData.children : treeData.children = children
  }
  return treeData
};

const updatedTreeData = recursiveFunc(treeData);

console.log(updatedTreeData)

Sign up to request clarification or add additional context in comments.

1 Comment

This works and the comments help a ton. The other answers give very creative and graceful solutions but for the intermediate developer that I am, this remains the most readable. Thank you.
2

recursive data are for recursive programs are for recursive data

You can write a recursive transform function using inductive reasoning. transform takes an input, o, and a function test that receives an object and returns true if (and only if) the object should be transformed.

  1. If the input, o, is an array, transform each child, v, with the same test
  2. Inductive reasoning says the input, o, is not an array. If the input is an object and it passes the test, prune this object and return only a reference to the input's id
  3. Inductive reasoning says the input, o, is an object and does not pass the test. Map over the input object and transform each child value, v, with the same test
  4. Inductive reasoning says the input, o, is not an array and not an object. The input is a simple value, such as a string "foo" or a number 1. Return the input un-transform-ed.
const transform = (o = {}, test = identity) =>
  Array.isArray(o)
    ? o.map(v => transform(v, test))          // 1
  : Object(o) === o
    ? test(o)
      ? o.id                                  // 2
      : objectMap(o, v => transform(v, test)) // 3
  : o                                         // 4

Offloading the work to objectMap function makes it easier for us to solve our problem and promotes code reuse through use of generic procedures -

const identity = x =>
  x

const objectMap = (o = {}, f = identity) =>
  Object.fromEntries(
    Object.entries(o).map(([ k, v ]) => [ k, f(v) ])
  )
  
const example =
  objectMap
    ( { a: 1, b: 2, c: 3, d: 4 }
    , x => x * x                // <-- square each value
    )

console.log(example)
// { a: 1, b: 4, c: 9, d: 16 }  // <-- squared

We use transform like a higher-order function, such as .filter -

const result =
  transform
    ( treeData   // <-- input
    , x => x.tagClass === "Variable" // <-- test
    )

console.log(result)

Output -

{ id: 1
, title: "Group1"
, tagClass: "Object"
, children:
  [ { id: 2
    , title: "Group2"
    , tagClass: "Object"
    , children: [ 3, 4 ] // <-- transformed 3 and 4
    }
  , { id: 5
    , title: "Group3"
    , tagClass: "Object"
    }
  , 6  // <-- transformed 6
  ]
}

code sandbox

Expand the snippet below to verify the results in your own browser -

const identity = x =>
  x

const objectMap = (o = {}, f = identity) =>
  Object.fromEntries(
    Object.entries(o).map(([ k, v ]) => [ k, f(v) ])
  )

const transform = (o = {}, test = identity) =>
  Array.isArray(o)
    ? o.map(v => transform(v, test))
  : Object(o) === o
    ? test(o)
      ? o.id
      : objectMap(o, v => transform(v, test))
  : o

const treeData =
  {id:1,title:"Group1",tagClass:"Object",children:[{id:2,title:"Group2",tagClass:"Object",children:[{id:3,title:"Tag1",tagClass:"Variable"},{id:4,title:"Tag2",tagClass:"Variable"}]},{id:5,title:"Group3",tagClass:"Object"},{id:6,title:"Tag3",tagClass:"Variable"}]}

const result =
  transform
    ( treeData
    , ({ tagClass = "" }) => tagClass === "Variable"
    )

console.log(JSON.stringify(result, null, 2))


improving readability

Recursion is a functional heritage and so using recursion with functional style yields the best results. Functional programming is all about reducing complexity and reusing well-defined generic functions. I think the following abstractions make transform even better -

const isArray =
  Array.isArray

const isObject = o =>
  Object(o) === o

const transform = (o = {}, test = identity) =>
  isArray(o)
    ? o.map(v => transform(v, test))        // 1
  : isObject(o) && test(o)
    ? o.id                                  // 2
  : isObject(o)
    ? objectMap(o, v => transform(v, test)) // 3
  : o                                       // 4

const result =
  transform
    ( treeData
    , ({ tagClass = "" }) =>
        tagClass === "Variable"
    )

console.log(result)

what the program doesn't do

  1. mutate the input or have other side effects
  2. make assumptions about children or tagIds
  3. unnecessarily check the length of arrays

Which should make that o.id feel a little out of place. What if we wanted to shape the results differently in different scenarios? Why should the id transformation be set in stone?

By defining another functional parameter, prune ...

const transform = (o = {}, test = identity, prune = identity) =>
  isArray(o)
    ? o.map(v => transform(v, test, prune))    // <-- pass prune
  : isObject(o) && test(o)
    ? prune(o)                                 // <-- prune!
  : isObject(o)
    ? objectMap(o, v => transform(v, test, prune)) // <-- pass prune
  : o

Now we can define how transform runs the test and performs the prune at the call site -

const result =
  transform
    ( treeData
    , ({ tagClass = "" }) =>     
        tagClass === "Variable"   // <-- test
    , ({ id = 0, title = "" }) => 
        ({ id, title })           // <-- return only { id, title }
    )

Output -

{ id: 1
, title: "Group1"
, tagClass: "Object"
, children:
  [ { id: 2
    , title: "Group2"
    , tagClass: "Object"
    , children:
        [ { id: 3, title: "Tag1" } // <--  prune { id, title }
        , { id: 4, title: "Tag2" } // <-- prune { id, title }
        ]
    }
  , { id: 5
    , title: "Group3"
    , tagClass: "Object"
    }
  , { id: 6, title: "Tag3" } // <-- prune { id, title }
  ]
}

6 Comments

As always, a well-written and informative answer. But note that the output doesn't quite match the OP's request, which wanted to collect the Variables into an array of ids, under the property tagIds.
Thanks Scott. I'm guilty of bending the question when I sense a code smell. It's hard to provide perfect advice because we don't know how the transformed result will actually be used.
This had got to be the most informative response I have ever received on SO. Lots to take in. I am going to reference this at work this week. If you wrote a book, I would be apt to read it. I didn't think this code smelled, but now I can understand why it does.
@Sean: You might find it informative to read other answer by Thankyou. Those answers are most often like this one: incredibly detailed, incredibly informative, and instructional in ways rarely seen in an open forum like StackOverflow. I've learned a lot from them over the last few years.
@Thankyou: Of course. The best questions offer some room for a variety of answers, including solutions that veer from the request to offer something potentially more useful. I would not have pointed it out if it was clear that you did so intentionally. But I may have missed it; I must admit to only skimming your answer, having recently answered several questions with a similar deepMap function.
|
2

This would be my approach:

const transform = ({children, ...rest}) => {
  const kids = (children || []) .filter (({tagClass}) => tagClass !== 'Variable')
  const tags = (children || []) .filter (({tagClass}) => tagClass === 'Variable')

  return {
    ... rest,
    ... (tags .length ? {tagIds: tags .map (({id}) => id)} : {}),
    ... (kids .length ? {children: kids .map (transform)} : {})
  }
}

const treeData = {id: 1, title: "Group1", tagClass: "Object", children: [{id: 2, title: "Group2", tagClass: "Object", children: [{id: 3, title: "Tag1", tagClass: "Variable"}, {id: 4, title: "Tag2", tagClass: "Variable"}]}, {id: 5, title: "Group3", tagClass: "Object"}, {id: 6, title: "Tag3", tagClass: "Variable"}]}

console .log (
  transform (treeData)
)

We separate out Variables from the others, collect the id properties of the variables into tagIds and then recur on the remaining children. This might be improved by a partition function that allowed

const [tags, kids] = 
  partition (({tagClass}) => tagClass === 'Variable')) (children) 

but I'll leave that to you.

4 Comments

loving the dense use of spreads here, almost looks like a pseudocode but it's real JS!
Thank you Scott. I would've never thought to solve this problem the way in which you provided above. It is a very short and sweet solution, but I feel as though my lack of expertise makes it a bit hard to grasp at first, with the 'dense' use of spreads. I truly look forward to the day I am able to think like you, from a technical point of view.
@Thankyou: I have felt like that about various ES6+ features since they were unlikely proposals. Arrow functions, rest/spread, and several other features have made for much more expressive code.
@Sean: this is so heavily invested in rest/spread syntax mostly because your requested output did not include empty tagIds or children. If it wouldn't bother you to have empty arrays for those nodes, we could replace ... (tags .length ? {tagIds: tags .map (({id}) => id)} : {}), with simply tagIds: tags .map (({id}) => id) and something similar for children. And note that the non-rest instances are not essential. They could just as easily have been written with Object.assign. Finally, I've been doing JS since 1998. This style did not come to me overnight! Keep at it!
1

Here's an in-place version using recursion. You can pass the parent into the recursive call or return to the parent whether the child should be kept in the children array or not and re-arranging the parent accordingly.

const moveVarIdToParent = root => {
  if (root.children) {
    const children = root.children.map(e => [e, moveVarIdToParent(e)]);
    root.tagIds = children.filter(e => e[1]).map(e => e[0].id);
    root.children = children.filter(e => !e[1]).map(e => e[0]);

    if (!root.children.length) {
      delete root.children;
    }

    if (!root.tagIds.length) {
      delete root.tagIds;
    }
  }

  return root.tagClass === "Variable";
};

const treeData = {
    id: 1,
    title: "Group1",
    tagClass: "Object",
    children: [
        {
            id: 2,
            title: "Group2",
            tagClass: "Object",
            children: [
                { id: 3, title: "Tag1", tagClass: "Variable" },
                { id: 4, title: "Tag2", tagClass: "Variable" },
            ],
        },
        { id: 5, title: "Group3", tagClass: "Object" },
        { id: 6, title: "Tag3", tagClass: "Variable" },
    ],
};

moveVarIdToParent(treeData);
console.log(treeData);

1 Comment

And yet another unique way to solve this. This works. Thank you for the approach. I have clearly been bested by a more brilliant mind, which I was hoping would happen. Cheers!

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.