Skip to the code
cf.

Apply a JSON Patch

A JSON Patch is a list of operations that describe changes to a JSON document, so a client can send only what changed. Write applyPatch(document, operations), which applies the operations in order and returns the new document. Support three operations: add, remove, and replace.

Each operation's path is a JSON Pointer to a place in the document. "" means the whole document. Any other path starts with / and lists keys separated by /: "/user/tags/0" means key user, then key tags, then index 0. Inside a key, ~1 stands for / and ~0 for ~, so "/a~1b" is the key a/b.

{ op: "add", path, value } sets a key of an object, whether it's there already or not. In an array, it inserts value at the index and moves the later items along: the index can be as large as the array's length, and - means the end. Adding at "" makes value the whole document.

{ op: "remove", path } deletes a key of an object, or takes an item out of an array and moves the later items back.

{ op: "replace", path, value } changes the value of a key or array item that's already there. Replacing at "" makes value the whole document.

An operation fails when a step on the way to its target is missing or isn't an object or array, when remove or replace targets a key or index that isn't there, when an array index isn't digits (or - for add) or is past the end, or when its op is unknown. Only own keys count as there, so a patch can't reach toString or __proto__. remove can't target "".

When an operation fails, throw an Error whose message includes its path. Don't change document, whether the patch works or fails.

const doc = { user: { name: "Ada", tags: ["math"] }, draft: true };
 
applyPatch(doc, [
  { op: "replace", path: "/user/name", value: "Grace" },
  { op: "add", path: "/user/tags/0", value: "code" },
  { op: "add", path: "/user/tags/-", value: "navy" },
  { op: "remove", path: "/draft" },
]);
// { user: { name: "Grace", tags: ["code", "math", "navy"] } }
 
doc.user.name; // "Ada": the original is unchanged
 
applyPatch(doc, [{ op: "remove", path: "/user/age" }]);
// throws an Error whose message includes "/user/age"

What the tests check

  1. adds, replaces, and removes object keys, in order
    const result = applyPatch({ name: "Ada", age: 36 }, [
      { op: "replace", path: "/name", value: "Grace" },
      { op: "add", path: "/city", value: "London" },
      { op: "add", path: "/city", value: "Paris" },
      { op: "remove", path: "/age" },
    ]);
    expect(result).toEqual({ name: "Grace", city: "Paris" });
    
  2. follows paths through nested objects and arrays
    const doc = { users: [{ name: "Ada", tags: ["math"] }, { name: "Alan", tags: [] }] };
    const result = applyPatch(doc, [
      { op: "replace", path: "/users/1/name", value: "Grace" },
      { op: "add", path: "/users/0/tags/1", value: "code" },
    ]);
    expect(result).toEqual({ users: [{ name: "Ada", tags: ["math", "code"] }, { name: "Grace", tags: [] }] });
    
  3. add inserts into an array at any index up to its length, and - appends
    expect(applyPatch([1, 2, 3], [{ op: "add", path: "/1", value: 9 }])).toEqual([1, 9, 2, 3]);
    expect(applyPatch([1, 2], [{ op: "add", path: "/2", value: 3 }])).toEqual([1, 2, 3]);
    expect(applyPatch([1, 2], [{ op: "add", path: "/-", value: 3 }, { op: "add", path: "/-", value: 4 }])).toEqual([1, 2, 3, 4]);
    expect(applyPatch({ list: [] }, [{ op: "add", path: "/list/0", value: "a" }])).toEqual({ list: ["a"] });
    
  4. remove closes the gap in an array, and replace overwrites an item
    expect(applyPatch(["a", "b", "c"], [{ op: "remove", path: "/0" }])).toEqual(["b", "c"]);
    expect(applyPatch(["a", "b", "c"], [{ op: "replace", path: "/1", value: "x" }])).toEqual(["a", "x", "c"]);
    
  5. reads ~1 as / and ~0 as ~ inside keys
    const doc = { "a/b": 1, "m~n": 2, "~1": 3 };
    const result = applyPatch(doc, [
      { op: "replace", path: "/a~1b", value: 10 },
      { op: "replace", path: "/m~0n", value: 20 },
      { op: "replace", path: "/~01", value: 30 },
    ]);
    expect(result).toEqual({ "a/b": 10, "m~n": 20, "~1": 30 });
    
  6. the empty path means the whole document
    expect(applyPatch({ a: 1 }, [{ op: "replace", path: "", value: [1, 2] }])).toEqual([1, 2]);
    expect(applyPatch({ a: 1 }, [{ op: "add", path: "", value: { b: 2 } }, { op: "add", path: "/c", value: 3 }])).toEqual({ b: 2, c: 3 });
    expect(() => applyPatch({ a: 1 }, [{ op: "remove", path: "" }])).toThrow(Error);
    
  7. returns a new document and never changes the original
    const doc = { user: { name: "Ada", tags: ["math"] }, n: 1 };
    const result = applyPatch(doc, [
      { op: "replace", path: "/user/name", value: "Grace" },
      { op: "add", path: "/user/tags/-", value: "code" },
      { op: "remove", path: "/n" },
    ]);
    expect(result).toEqual({ user: { name: "Grace", tags: ["math", "code"] } });
    expect(result).not.toBe(doc);
    expect(doc).toEqual({ user: { name: "Ada", tags: ["math"] }, n: 1 });
    expect(() => applyPatch(doc, [{ op: "remove", path: "/n" }, { op: "remove", path: "/missing" }])).toThrow(Error);
    expect(doc).toEqual({ user: { name: "Ada", tags: ["math"] }, n: 1 });
    
  8. throws, naming the path, when a key or a step on the way isn't there
    expect(() => applyPatch({ a: {} }, [{ op: "remove", path: "/a/b" }])).toThrow("/a/b");
    expect(() => applyPatch({ a: {} }, [{ op: "replace", path: "/a/b", value: 1 }])).toThrow("/a/b");
    expect(() => applyPatch({}, [{ op: "add", path: "/x/y", value: 1 }])).toThrow("/x/y");
    expect(() => applyPatch({ a: 5 }, [{ op: "add", path: "/a/b", value: 1 }])).toThrow("/a/b");
    expect(() => applyPatch({ a: null }, [{ op: "add", path: "/a/b", value: 1 }])).toThrow("/a/b");
    expect(() => applyPatch({}, [{ op: "remove", path: "/toString" }])).toThrow("/toString");
    try {
      expect(() => applyPatch({}, [{ op: "add", path: "/__proto__/polluted", value: true }])).toThrow("/__proto__/polluted");
      expect(({}).polluted).toBeUndefined();
    } finally {
      delete Object.prototype.polluted;
    }
    
  9. throws, naming the path, for a bad array index or an unknown op
    expect(() => applyPatch({ list: [1] }, [{ op: "add", path: "/list/2", value: 0 }])).toThrow("/list/2");
    expect(() => applyPatch({ list: [1] }, [{ op: "replace", path: "/list/1", value: 0 }])).toThrow("/list/1");
    expect(() => applyPatch({ list: [1] }, [{ op: "remove", path: "/list/-" }])).toThrow("/list/-");
    expect(() => applyPatch({ list: [1] }, [{ op: "add", path: "/list/x", value: 0 }])).toThrow("/list/x");
    expect(() => applyPatch({ list: [1] }, [{ op: "add", path: "/list/-1", value: 0 }])).toThrow("/list/-1");
    expect(() => applyPatch({ a: 1 }, [{ op: "move", path: "/a" }])).toThrow("/a");
    

Learn more

solution.jsSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).