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.
- If the input,
o, is an array, transform each child, v, with the same test
- 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
- 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
- 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
- mutate the input or have other side effects
- make assumptions about
children or tagIds
- 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 }
]
}
tagIds: [3,4]from the second code snippet