JSON Pointer、Patch、Merge Patch
要点:三つの短い仕様が変更プロトコルを構成します。Pointer は位置を特定し、Patch は正確に編集し、Merge Patch は部分的にマージします。選択を誤ると配列と並行性で痛みます。

要点:位置指定には JSON Pointer、フィルタや再帰には JSONPath を使いましょう。配列の挿入・削除、null の書き込み、バージョン検証は JSON Patch に任せ、Merge Patch は平坦な設定やフォームリソースに取っておきます。
位置特定と変更は別物で、仕様はそれを三つの文書に分けています。混同の代償はたいてい「配列全体が置き換わったのに誰も気づかない」ことです。
1. JSON Pointer:最も短い位置指定構文
RFC 6901 はただ一つのことをします。文字列で文書内のノードを指すのです。
"" → 文書全体
"/store" → ルート下の store メンバー
"/store/book" → さらに一段深い
"/store/book/0/title" → 配列の 0 番目
三つの規則:
- 空文字列はルートノードそのものを指し、「空のノード」ではありません。
- 各
/は参照トークンを一つ区切ります。配列は十進のインデックスを使います(先頭ゼロは許されないので01は不正)。 - トークン内では
~を~0、/を~1と書きます。/より先に~を置換してください。さもないと二重エスケープになります。
/**
* 単一の参照トークンを JSON Pointer のセグメントにエスケープする。
* @param token 元のキー名またはインデックス
* @returns エスケープされたトークン
*/
function escapePointerToken(token: string): string {
return token.replace(/~/g, '~0').replace(/\//g, '~1');
}
/**
* キー名の配列を JSON Pointer に連結する。
* @param tokens キー名またはインデックスの配列
* @returns / で始まるポインタ文字列
*/
function buildPointer(tokens: Array<string | number>): string {
return tokens.map((token) => `/${escapePointerToken(String(token))}`).join('');
}
キー自体に / が含まれるときが最もよく分かります。{"a/b": 1} のポインタは /a~1b で、/a/b ではありません(後者は a の下の b を意味します)。
URL 内で使うときはパーセントエンコーディングも必要です。フラグメント内で # と ~ が特別な意味を持つからです。
2. JSON Patch:六つの操作からなる原子的な列
RFC 6902 は変更を操作の配列で記述し、メディアタイプは application/json-patch+json です。
[
{ "op": "test", "path": "/version", "value": 7 },
{ "op": "replace", "path": "/name", "value": "新しい名前" },
{ "op": "add", "path": "/tags/-", "value": "json" },
{ "op": "remove", "path": "/temp" },
{ "op": "copy", "from": "/name", "path": "/alias" },
{ "op": "move", "from": "/tags/0", "path": "/tags/1" }
]
| 操作 | 意味論 | 境界 |
|---|---|---|
add |
メンバーを追加、または配列要素を挿入 | 配列インデックスは長さと等しくてもよい(追加)。- は末尾を意味する |
remove |
削除 | パスは存在しなければならない |
replace |
既存の値を置換 | パスは存在しなければならない(remove + add と等価だが、先にパスが必要) |
move |
移動 | ノードを自身のサブパス下へ移動できない |
copy |
コピー | ソースは存在しなければならない |
test |
等価性を表明 | 失敗するとパッチ全体が適用されない。楽観的ロックに使う |
鍵となる性質は原子性です。いずれかの操作が失敗するとパッチ全体がロールバックし、「最初の三つは適用され、四つ目で失敗した」という中間状態は存在しません。
これが PATCH リクエストボディに最適な理由です。
2.1 楽観的並行性制御に test を使う
[
{ "op": "test", "path": "/etag", "value": "a3f9c1" },
{ "op": "replace", "path": "/status", "value": "paid" }
]
クライアントはまず etag を GET し、それを test 操作に書きます。並行書き込みでは二番目のリクエストの test が失敗し、サーバーは
409 Conflict または 412 Precondition Failed を返します。最後の書き込みが勝つ方式よりはるかに安全で、分散ロックも不要です。
2.2 配列操作が最も誤りやすい
// インデックス 1 に挿入したい
{ "op": "add", "path": "/items/1", "value": {...} } // 正しい:挿入。元のインデックス 1 以降が後ろにずれる
// インデックス 1 を置換したい
{ "op": "replace", "path": "/items/1", "value": {...} } // 正しい:置換
配列インデックス n への add は挿入であり、上書きではありません。範囲外のインデックス(n > length)はエラーにしなければなりません。
一部の実装はこれを寛容に扱うため、同じパッチがサーバーによって異なる振る舞いをします。
3. Merge Patch:対象に似た断片
RFC 7386 には四つの規則しかありません。
- 対象がオブジェクトでパッチもオブジェクト → 再帰的にマージ。
- パッチ内のキーの値が
null→ 対象からそのキーを削除。 - パッチ内のキーの値がオブジェクトでない → 直接置換。
- パッチ自体がオブジェクトでない → 対象全体を置換。
// 対象
{ "a": "b", "c": { "d": "e", "f": "g" } }
// パッチ
{ "a": "z", "c": { "f": null } }
// 結果
{ "a": "z", "c": { "d": "e" } }
メディアタイプは application/merge-patch+json です。「部分更新」のように読めるので、特に誤用しやすいです。
- 配列は丸ごと置き換えるしかない。
list[2]を削除したい? Merge Patch にはできません。 nullを書けない。nullはすでに削除の意味に取られています。- 「このフィールドに触れない」と「このフィールドを削除する」の間の第三の状態を表現できない(たとえば明示的に空にする)。
4. 能力マトリクス:どれを選ぶか
| 要件 | JSON Patch | Merge Patch |
|---|---|---|
| 特定の配列要素を正確に置換 | ✓ | ✗ |
| 配列から 1 要素を削除 | ✓ | ✗ |
| 複数フィールドを部分更新 | ✓(複数の replace) | ✓(最も簡潔) |
null 値を書き込む |
✓ | ✗ |
| 並行性制御(バージョン表明) | ✓(test) | ✗(If-Match 経由のみ) |
| パッチの可読性 | 中程度 | 良い(データ自体に見える) |
| 実装の複雑さ | 高い | 低い |
| 適するリソース形状 | 深いネスト、リスト的 | 平坦な設定、フォーム |
経験則:
- フォーム、設定ページ、設定リソース → Merge Patch。フロントエンドは変更したフィールドだけを送信します。
- リストの追加と削除、状態遷移、並行性保護が必要なもの → JSON Patch。
- 両者は共存できます。エンドポイントごとに異なる
Content-Typeを宣言し、サーバーが型で振り分けます。
5. 実装上の注意
import { applyPatch, compare } from 'fast-json-patch';
const document = { version: 7, items: [{ id: 1 }, { id: 2 }] };
const patch = compare(document, { version: 8, items: [{ id: 1 }] });
// test 操作は既定で検証される。mutateDocument を true にするとインプレースで変更するので注意
const result = applyPatch(document, patch, true, false);
console.log(result.newDocument);
見落としやすい四点:
- 共有オブジェクトをインプレースで変更しない。失敗したパッチが半分変更された状態を残します(実装がロールバックを保証しない限り)。
- パスの検証:外部由来のパッチはすべての
path/fromを検証し、__proto__やconstructorのようなキーを拒否します。 - サイズ制限:パッチのエントリ数に上限を設け、単一のパッチが数十万の操作を行わないようにします。
- 監査:パッチは監査ログに自然に向きます。「何が変わったか」の完全な記述だからです。
6. エラーの意味論
| 状況 | 推奨ステータスコード | 説明 |
|---|---|---|
| パッチが配列でない / 構造的に不正 | 400 Bad Request |
リクエストボディの形式が誤り |
path が参照するノードが存在しない |
422 Unprocessable Entity |
文法的には正しいが意味的に実行不能 |
test 操作が失敗 |
409 または 412 |
バージョン競合を示す |
| そのパスを変更する権限がない | 403 Forbidden |
リソースの存在を意図的に隠さない限り 404 で偽装しない |
400 と 422 を区別することは重要です。前者は「あなたのパッチが理解できない」、後者は「理解できるが、このデータではその変更を許さない」です。
7. チェックリスト
- [ ] エンドポイントが正しい
Content-Type(json-patch+json/merge-patch+json)を宣言している。 - [ ] パッチの適用が原子的で、失敗時に部分的な結果を残さない。
- [ ] 外部パッチをパスのホワイトリストとエントリ数上限で検証している。
- [ ] 並行性制御に
testかIf-Matchを使っている。 - [ ] パッチを監査ログに記録している。
- [ ] 配列の場面では明示的に JSON Patch を選び、Merge Patch を当てにしていない。
FAQ
JSON Patch と Merge Patch はどう決めればよいですか?
特定の配列要素の置換や削除、null 値の書き込み、バージョンの表明が必要なら JSON Patch を使います。数フィールドだけを更新するフォームや設定リソースには Merge Patch を使います。Merge Patch は配列の 1 要素を削除できず、null も書けません。null はすでに削除の意味だからです。
配列インデックス n への add が上書きでないのはなぜですか?
add は配列に挿入するからです。インデックス n の要素とそれ以降は後ろにずれます。既存要素を置き換えるには replace を使い、パスが先に存在しなければなりません。範囲外のインデックス(n > length)はエラーにすべきです。寛容な実装は同じパッチをサーバー間で異なる振る舞いにします。
元のオブジェクトをインプレースで変更すべきですか?
いいえ。実装がロールバックを保証しない限り、失敗したパッチは半分変更された状態を残します。深いコピーや structured clone した複製にパッチを適用し、成功後に参照を差し替えてください。