recursive diff
Almost 3 years later, I'm happy to provide a refreshed answer to this question.
We start with two objects that are different
const x =
{ a: 1, b: 2, c: 3 }
const y =
{ a: 1, b: 3, d: 4 }
console.log (diff (x, y))
// => ???
Both objects have the same a
property. The b
property is not the same. Only x
has a c
property, and only y
has a d
property. So what should ???
be exactly?
From the perspective of diff
, the relationship between our input objects a
and b
could be completely arbitrary. To communicate the which object contributes a difference, diff
assigns descriptors left
and right
console.log (diff (x, y))
// { b: { left: 2, right: 3 }, c: { left: 3 }, d: { right: 4 } }
In the output above we can see
- which properties are different –
b
, c
, and d
- which object contributed the difference -
left
and/or right
- the "different" value - for example the left
b
has a value of 2, the right b
has a value of 3; or the left c
has a value of 3, the right c
has a value of undefined
Before we get into the implementation of this function, we'll first examine a more complex scenario involving deeply nested objects
const x =
{ a: { b: { c: 1, d: 2, e: 3 } } }
const y =
{ a: { b: { c: 1, d: 3, f: 4 } } }
console.log (diff (x, y))
// { a: { b: { d: { left: 2, right: 3 }, e: { left: 3 }, f: { right: 4 } } } }
As we can see above, diff
returns a structure that matches our inputs. And finally we expect the diff
of two objects that are the same to return an "empty" result
const x1 =
{ a: 1, b: { c: { d: 2 } } }
const x2 =
{ a: 1, b: { c: { d: 2 } } }
console.log (diff (x1, x2))
// {}
Above we describe a diff
function that does not care about the input objects it is given. The "left" object can contain keys the "right" object does not contain, and vice versa, yet we still must detect changes from either side. Starting from a high-level, this is how we'll be approaching the problem
const diff = (x = {}, y = {}) =>
merge
( diff1 (x, y, "left")
, diff1 (y, x, "right")
)
diff1
We take a "one-sided" diff using diff1
described as the "left" relation, and we take another one-sided diff with the input objects reversed described as the "right" relation, then we merge
the two results together
Our work is divided for us in tasks that are easier to accomplish now. diff1
only needs to detect half of the necessary changes and merge
simply combines the results. We'll start with diff1
const empty =
{}
const isObject = x =>
Object (x) === x
const diff1 = (left = {}, right = {}, rel = "left") =>
Object.entries (left)
.map
( ([ k, v ]) =>
isObject (v) && isObject (right[k])
? [ k, diff1 (v, right[k], rel) ]
: right[k] !== v
? [ k, { [rel]: v } ]
: [ k, empty ]
)
.reduce
( (acc, [ k, v ]) =>
v === empty
? acc
: { ...acc, [k]: v }
, empty
)
diff1
accepts two input objects and a relationship descriptor, rel
. This descriptor defaults to "left"
which is the default "orientation" of the comparison. Below, notice that diff1
only provides half of the result we need. Reversing the arguments in a second call to diff1
provides the other half.
const x =
{ a: 1, b: 2, c: 3 }
const y =
{ a: 1, b: 3, d: 4 }
console.log (diff1 (x, y, "left"))
// { b: { left: 2 }, c: { left: 3 } }
console.log (diff1 (y, x, "right"))
// { b: { right: 3 }, d: { right: 4 } }
Also worth noting is the relationship labels "left"
and "right"
are user-definable. For example, if you have a known relationship between the objects you're comparing and you wish to provide more descriptive labels in the diff output ...
const customDiff = (x = {}, y = {}) =>
merge
( diff1 (x, y, "original")
, diff1 (y, x, "modified")
)
customDiff
( { host: "localhost", port: 80 }
, { host: "127.0.0.1", port: 80 }
)
// { host: { original: 'localhost', modified: '127.0.0.1' } }
In the above example, it may be easier to work with the output in other areas of your program because labels original
and modified
are more descriptive than left
and right
.
merge
All that remains is merging the two half diffs into a complete result. Our merge
function also works generically and accepts any two objects as input.
const x =
{ a: 1, b: 1, c: 1 }
const y =
{ b: 2, d: 2 }
console.log (merge (x, y))
// { a: 1, b: 2, c: 1, d: 2 }
In the event each object contains a property whose value is also an object, merge
will recur and merge the nested objects as well.
const x =
{ a: { b: { c: 1, d: 1 } } }
const y =
{ a: { b: { c: 2, e: 2 } }, f: 2 }
console.log (merge (x, y))
// { a: { b: { c: 2, d: 1, e: 2 } }, f: 2 }
Below we encode our intentions in merge
const merge = (left = {}, right = {}) =>
Object.entries (right)
.reduce
( (acc, [ k, v ]) =>
isObject (v) && isObject (left [k])
? { ...acc, [k]: merge (left [k], v) }
: { ...acc, [k]: v }
, left
)
And that's the whole kit and caboodle! Expand the code snippet below to run a code demonstration in your own browser
const empty =
{}
const isObject = x =>
Object (x) === x
const diff1 = (left = {}, right = {}, rel = "left") =>
Object.entries (left)
.map
( ([ k, v ]) =>
isObject (v) && isObject (right[k])
? [ k, diff1 (v, right[k], rel) ]
: right[k] !== v
? [ k, { [rel]: v } ]
: [ k, empty ]
)
.reduce
( (acc, [ k, v ]) =>
v === empty
? acc
: { ...acc, [k]: v }
, empty
)
const merge = (left = {}, right = {}) =>
Object.entries (right)
.reduce
( (acc, [ k, v ]) =>
isObject (v) && isObject (left [k])
? { ...acc, [k]: merge (left [k], v) }
: { ...acc, [k]: v }
, left
)
const diff = (x = {}, y = {}) =>
merge
( diff1 (x, y, "left")
, diff1 (y, x, "right")
)
const x =
{ a: { b: { c: 1, d: 2, e: 3 } } }
const y =
{ a: { b: { c: 1, d: 3, f: 4 } } }
console.log (diff (x, y))
// { a: { b: { d: { left: 2, right: 3 }, e: { left: 3 }, f: { right: 4 } } } }
console.log (diff (diff (x,y), diff (x,y)))
// {}
remarks
As we look back at our diff
function, I want to highlight one important part of its design. A good portion of the work is handled by the merge
function which is completely separate from diff
, yet a tough nut to crack on its own. Because we separated our concerns into singular functions, it's now easy to reuse them in other areas of your program. Where we wanted diff
, we got it, and we got intuitive deep merge
functionality for free.
extra: support for arrays
Our diff
function is very convenient as it can crawl deeply nested objects, but what if one of our object properties is an array? It'd be nice if we could diff arrays using the same technique.
Supporting this feature requires non-trivial changes to the code above. However, the majority of the structure and reasoning stays the same. For example, diff
is completely unchanged
// unchanged
const diff = (x = {}, y = {}) =>
merge
( diff1 (x, y, "left")
, diff1 (y, x, "right")
)
To support arrays in merge
, we introduce a mutation helper mut
which assigns a [ key, value ]
pair to a given object, o
. Arrays are considered objects too, so we can update both arrays and objects using the same mut
function
const mut = (o, [ k, v ]) =>
(o [k] = v, o)
const merge = (left = {}, right = {}) =>
Object.entries (right)
.map
( ([ k, v ]) =>
isObject (v) && isObject (left [k])
? [ k, merge (left [k], v) ]
: [ k, v ]
)
.reduce (mut, left)
Shallow merges work as expected
const x =
[ 1, 2, 3, 4, 5 ]
const y =
[ , , , , , 6 ]
const z =
[ 0, 0, 0 ]
console.log (merge (x, y))
// [ 1, 2, 3, 4, 5, 6 ]
console.log (merge (y, z))
// [ 0, 0, 0, <2 empty items>, 6 ]
console.log (merge (x, z))
// [ 0, 0, 0, 4, 5, 6 ]
And deep merges too
const x =
{ a: [ { b: 1 }, { c: 1 } ] }
const y =
{ a: [ { d: 2 }, { c: 2 }, { e: 2 } ] }
console.log (merge (x, y))
// { a: [ { b: 1, d: 2 }, { c: 2 }, { e: 2 } ] }
Supporting arrays in diff1
is considerably more challenging
const diff1 = (left = {}, right = {}, rel = "left") =>
Object.entries (left)
.map
( ([ k, v ]) =>
isObject (v) && isObject (right[k])
? [ k, diff1 (v, right[k], rel) ]
: right[k] !== v
? [ k, { [rel]: v } ]
: [ k, {} ]
)
.filter
( ([ k, v ]) =>
Object.keys (v) .length !== 0
)
.reduce
( mut
, isArray (left) && isArray (right) ? [] : {}
)
But with these changes in place, we can now deeply compare objects that contain arrays – and even arrays containing objects!
const x =
{ a: 1, b: [ { c: 1 }, { d: 1 }, { e: 1 } ] }
const y =
{ a: 1, b: [ { c: 2 }, { d: 1 }, 5, 6 ], z: 2 }
console.log (diff (x, y))
// { b:
// [ { c: { left: 1, right: 2 } }
// , <1 empty item>
// , { left: { e: 1 }, right: 5 }
// , { right: 6 }
// ]
// , z: { right: 2 }
// }
Because diff1
carefully changes its behavior based on its input types, we get array diffing for free
const x =
[ 1, 2, 3, 4 ]
const y =
[ 1, 2, 9 ]
const z =
[ 1, 2, 9 ]
console.log (diff (x, y))
// [ <2 empty items>, { left: 3, right: 9 }, { left: 4 } ]
console.log (diff (y, z))
// []
Run the full program in your browser below
const isObject = x =>
Object (x) === x
const isArray =
Array.isArray
const mut = (o, [ k, v ]) =>
(o [k] = v, o)
const diff1 = (left = {}, right = {}, rel = "left") =>
Object.entries (left)
.map
( ([ k, v ]) =>
isObject (v) && isObject (right[k])
? [ k, diff1 (v, right[k], rel) ]
: right[k] !== v
? [ k, { [rel]: v } ]
: [ k, {} ]
)
.filter
( ([ k, v ]) =>
Object.keys (v) .length !== 0
)
.reduce
( mut
, isArray (left) && isArray (right) ? [] : {}
)
const merge = (left = {}, right = {}) =>
Object.entries (right)
.map
( ([ k, v ]) =>
isObject (v) && isObject (left [k])
? [ k, merge (left [k], v) ]
: [ k, v ]
)
.reduce (mut, left)
const diff = (x = {}, y = {}) =>
merge
( diff1 (x, y, "left")
, diff1 (y, x, "right")
)
const x =
{ a: 1, b: [ { c: 1 }, { d: 1 }, { e: 1 } ] }
const y =
{ a: 1, b: [ { c: 2 }, { d: 1 }, 5, 6 ], z: 2 }
console.log (diff (x, y))
// { b:
// [ { c: { left: 1, right: 2 } }
// , <1 empty item>
// , { left: { e: 1 }, right: 5 }
// , { right: 6 }
// ]
// , z: { right: 2 }
// }
shallow diff
The previous version of this answer provided an object diff
function for comparing objects with the same keys and comparing objects with different keys, but neither solution performed the diff recursively on nested objects.
recursive intersection
In this related Q&A, we take two input objects and compute a recursive intersect
instead of diff
.
{A: 10, B: 20, C: 30, D: 90}, {A: 10, B: 22, C: 30}
? – Alcaldediff = {changed: {B:22}, removed:null, created:{}}
. It will be great question, i think – Hiro