JavaScripthardJavaScript

JSON Diff/Patch

Compare two JSON objects and generate patches

01

The problem

Need to find differences between JSON objects and apply patches

02

The solution

JavaScript
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;
}

03

Parameters

obj1object

Original object

obj2object

Modified object

04

Put it to work

Example
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);