The problem
Need to find differences between JSON objects and apply patches
The solution
class JsonDiff {
static diff(obj1, obj2) {
const patches = [];
function compare(path, val1, val2) {
// Both are primitives or null/undefined
if (typeof val1 !== 'object' || typeof val2 !== 'object' ||
val1 === null || val2 === null ||
val1 === undefined || val2 === undefined) {
if (val1 !== val2) {
patches.push({
op: 'replace',
path: formatPath(path),
value: val2
});
}
return;
}
// Both are arrays
if (Array.isArray(val1) && Array.isArray(val2)) {
// Find differences in array items
const maxLength = Math.max(val1.length, val2.length);
for (let i = 0; i < maxLength; i++) {
if (i < val1.length && i < val2.length) {
compare([...path, i], val1[i], val2[i]);
} else if (i < val1.length) {
// Item removed
patches.push({
op: 'remove',
path: formatPath([...path, i])
});
} else {
// Item added
patches.push({
op: 'add',
path: formatPath([...path, i]),
value: val2[i]
});
}
}
return;
}
// Both are objects
const allKeys = new Set([
...Object.keys(val1),
...Object.keys(val2)
]);
for (const key of allKeys) {
if (key in val1 && key in val2) {
compare([...path, key], val1[key], val2[key]);
} else if (key in val1) {
// Key removed
patches.push({
op: 'remove',
path: formatPath([...path, key])
});
} else {
// Key added
patches.push({
op: 'add',
path: formatPath([...path, key]),
value: val2[key]
});
}
}
}
compare([], obj1, obj2);
return patches;
}
static patch(obj, patches) {
const result = JSON.parse(JSON.stringify(obj));
for (const patch of patches) {
applyPatch(result, patch);
}
return result;
}
static createPatch(fromObj, toObj) {
return this.diff(fromObj, toObj);
}
static applyPatch(obj, patches) {
return this.patch(obj, patches);
}
static invert(patches) {
const inverted = [];
for (const patch of patches.slice().reverse()) {
const invertedPatch = { ...patch };
switch (patch.op) {
case 'add':
invertedPatch.op = 'remove';
delete invertedPatch.value;
break;
case 'remove':
invertedPatch.op = 'add';
// Note: We need original value here, which we don't have
// This is a limitation
break;
case 'replace':
// Keep as replace, value will need to be the original
// This is also a limitation
break;
}
inverted.push(invertedPatch);
}
return inverted;
}
}
function applyPatch(obj, patch) {
const path = parsePath(patch.path);
let current = obj;
// Navigate to parent
for (let i = 0; i < path.length - 1; i++) {
const key = path[i];
if (typeof key === 'number' && !Array.isArray(current)) {
// Convert object to array if needed
current = convertToArray(current);
}
if (!(key in current)) {
current[key] = typeof path[i + 1] === 'number' ? [] : {};
}
current = current[key];
}
const lastKey = path[path.length - 1];
switch (patch.op) {
case 'add':
if (typeof lastKey === 'number') {
if (!Array.isArray(current)) {
current = convertToArray(current);
}
current.splice(lastKey, 0, patch.value);
} else {
current[lastKey] = patch.value;
}
break;
case 'remove':
if (typeof lastKey === 'number') {
current.splice(lastKey, 1);
} else {
delete current[lastKey];
}
break;
case 'replace':
if (typeof lastKey === 'number' && !Array.isArray(current)) {
current = convertToArray(current);
}
current[lastKey] = patch.value;
break;
}
}
function formatPath(pathArray) {
return '/' + pathArray.map(segment => {
if (typeof segment === 'number') {
return segment.toString();
}
return segment.replace(/~/g, '~0').replace(/\//g, '~1');
}).join('/');
}
function parsePath(pathString) {
if (!pathString.startsWith('/')) {
throw new Error('Path must start with /');
}
if (pathString === '/') {
return [];
}
return pathString.slice(1).split('/').map(segment => {
const decoded = segment.replace(/~1/g, '/').replace(/~0/g, '~');
if (/^\d+$/.test(decoded)) {
return parseInt(decoded, 10);
}
return decoded;
});
}
function convertToArray(obj) {
const arr = [];
const keys = Object.keys(obj).map(Number).sort((a, b) => a - b);
for (const key of keys) {
arr[key] = obj[key];
}
return arr;
}Parameters
obj1objectOriginal object
obj2objectModified object
Put it to work
const original = {
name: 'John',
age: 30,
address: {
city: 'New York',
zip: '10001'
},
hobbies: ['reading', 'coding']
};
const modified = {
name: 'John',
age: 31,
address: {
city: 'Boston',
zip: '10001'
},
hobbies: ['reading', 'gaming', 'hiking'],
email: '[email protected]'
};
// Find differences
const patches = JsonDiff.diff(original, modified);
console.log(patches);
// [
// { op: 'replace', path: '/age', value: 31 },
// { op: 'replace', path: '/address/city', value: 'Boston' },
// { op: 'replace', path: '/hobbies/1', value: 'gaming' },
// { op: 'add', path: '/hobbies/2', value: 'hiking' },
// { op: 'add', path: '/email', value: '[email protected]' }
// ]
// Apply patches to original
const patched = JsonDiff.patch(original, patches);
console.log(patched);
// Matches 'modified' object
// Create patch from diff
const diffPatch = JsonDiff.createPatch(original, modified);
// Apply patch
const result = JsonDiff.applyPatch(original, diffPatch);
// More complex example with arrays
const before = {
items: [
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' }
]
};
const after = {
items: [
{ id: 1, name: 'Item One' },
{ id: 3, name: 'Item 3' },
{ id: 2, name: 'Item 2' }
]
};
const complexPatches = JsonDiff.diff(before, after);
console.log(complexPatches);