JSON Schema の再利用:$ref、$defs、allOf/oneOf の合成
要点:$ref は再利用を、allOf と oneOf は合成を解決し、両者を重ねたところで問題が起きます。$id、$defs、条件、再帰、バンドル、デバッグを扱います。

要点:まず共通構造を $defs に抽出して $ref で参照し、そのうえで allOf/oneOf に手を伸ばしましょう。分岐が同時に一致しうるなら anyOf か const タグを使い、oneOf はちょうど一つの一致が本当に必要な場合に取っておきます。
コピー&ペーストは十分を節約し、三か月後に静かなフォークをもたらします。その頃にはどのコピーが正しいか誰も覚えていません。
1. 三層の再利用 — ファイル分割の前に一つ選ぶ
フィールドが増え始めたときの最初の衝動は、すべての制約を一つのファイルに詰め込むことです。出荷は最速ですが、数百行を超えると、フィールドを変えるたびに三か所を同期しなければなりません。一つ漏らせば「ローカルでは通るが本番で 500」になります。再利用とは、事実を一度だけ書き、他では参照することです。
JSON Schema は三層を提供し、それぞれ範囲が異なります。
| 層 | 担い手 | 範囲 | 典型的な用途 |
|---|---|---|---|
| 部分構造 | $defs + $ref |
一つのスキーマファイル | 住所、金額、ページネーション、エラー形状 |
| ファイル | 独立したスキーマ + $id |
一サービス内の複数ファイル | ユーザー、注文、商品エンティティ |
| 契約ライブラリ | スキーマリポジトリ + バンドル成果物 | 複数サービス、複数言語 | OpenAPI components、公開契約 |
ほとんどのプロジェクトでは第一層で十分です。ファイル分割が割に合うのは、二つのサービスが本当に同じ契約を共有する場合か、スキーマが他言語に消費される公開成果物の場合だけです。早すぎる分割は $id 解決と相対パスのデバッグを難しくし、そのコストは重複を避けて節約した分を通常上回ります。
簡単な経験則:構造が二か所に現れるなら一つのコピーは許容でき、三か所目に現れたら $defs に昇格させ、他言語や他リポジトリが読む必要があるときだけ、独自の $id を持つ独立ファイルに値します。
2. $id は同一性を定め、$ref はジャンプを行う
$id はコメントではありません。スキーマの基底 URI を宣言します。$ref の値は URI 参照で、現在の基底 URI に対して解決されて絶対 URI になり、その # 以降のフラグメントがノードを指します。基底 URI が誤っていれば、すべての相対参照が別の場所を指しますが、エラーは通常「参照を解決できない」としか言わず、誤ったホストを名指ししません。
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://bbdrug.com/schemas/order.json",
"$defs": {
"address": {
"$anchor": "address",
"type": "object",
"required": ["city"],
"properties": {
"city": { "type": "string", "minLength": 1 },
"detail": { "type": "string" }
}
}
},
"type": "object",
"required": ["shipTo"],
"properties": {
"shipTo": { "$ref": "#/$defs/address" },
"billTo": { "$ref": "#address" }
}
}
shipTo は JSON Pointer を、billTo はアンカーを使い、どちらも同じノードに着地します。以下の表は各形式とその解決を対応づけます。
| 形式 | 指す先 | ドラフト | 備考 |
|---|---|---|---|
# |
現在のスキーマのルート | すべて | 再帰でよく使う |
#/$defs/address |
$defs 下のノード |
2019-09 以降 | 最も明確。推奨 |
#address |
名前付きアンカー | 2020-12 の $anchor |
draft-07 では $id: "#address" と書いた |
common.json |
基底 URI からの相対ファイル | すべて | 登録かバンドルが必要 |
https://example.com/x.json |
絶対 URI | すべて | 本番での実行時取得は避ける |
微妙な罠が一つ。$defs 内のサブスキーマが独自の $id を宣言すると、それがその下のすべての新しい基底 URI になります。別ファイルからコピーしたスキーマを貼り付けると、残った $id が相対参照を元のホストへ向け、バリデータは実際に解決を試みたときにしか気づきません。
3. $defs はコンテナであり、検証の場ではない
$defs は普通のマップです。$ref がそこを指すまで、中の何も効力を発しません。$defs の先頭に required リストがあっても、参照が来なければ、どれほど厳しく見えても何も検証しません。これはバリデータのバグではなく、仕様が意図したとおりの動作です。
{
"$defs": {
"money": {
"type": "string",
"pattern": "^[0-9]+\\.[0-9]{2}$",
"description": "浮動小数点のずれを避ける、小数 2 桁の金額文字列"
},
"page": { "type": "integer", "minimum": 1 }
},
"properties": {
"total": { "$ref": "#/$defs/money" },
"page": { "$ref": "#/$defs/page" }
}
}
チームで決めておく価値のある慣習がいくつかあります。
$defs内のキーに意味論はありません。address、Address、addrはすべて合法なので、スタイルを合意してください。- 2019-09 より前のキーワードは
definitionsでした。どちらも$refで指せますが、strict モードはdefinitionsを未知のキーワードとして報告することがあります。 $defsは任意のスキーマを保持できます。allOfの断片や、独自の$idを持つ独立サブスキーマも含みます。$defsをテンプレート継承として扱わないでください。定義を保存するだけで、意味は参照する文脈から生まれます。
4. allOf、anyOf、oneOf は交換可能ではない
三つとも複数の分岐を扱いますが、受理規則が異なります。データが二つの分岐に一致するとき、allOf と anyOf は通り、oneOf は失敗します。「スキーマが壊れている」と結論づける最も一般的な理由です。
| キーワード | 最小一致数 | 最大一致数 | 二つの分岐に一致するデータ | エラーの品質 | 典型的な用途 |
|---|---|---|---|---|---|
allOf |
すべて | 無制限 | 通る | 良い。制約を一つずつ | 制約断片の合成 |
anyOf |
1 | 無制限 | 通る | 悪い。分岐が積み重なる | 「メールか電話のどちらか」 |
oneOf |
1 | 1 | 失敗 | 悪い | 判別可能なユニオン |
not |
0 | 0 | 失敗 | 最悪 | 少数の値の除外。稀 |
{
"allOf": [
{ "$ref": "#/$defs/auditable" },
{ "$ref": "#/$defs/softDeletable" },
{ "required": ["email"] }
],
"unevaluatedProperties": false
}
allOf はオブジェクト定義をマージせず、すべての分岐が成り立つことだけを要求します。各分岐は独立に評価されるので、ある分岐内の additionalProperties: false はその分岐が宣言したキーしか知らず、別の分岐が宣言したフィールドを追加分として指摘します。2020-12 での修正は、未知プロパティの方針をトップレベルに置き、unevaluatedProperties: false を使うことです。これはすべての分岐の後に一度だけ評価されます。
anyOf は「複数の合法な形」に使い、「複数の相互排他的な形」には使いません。二つの分岐が同時に一致しうるなら、oneOf は有名な無意味なエラー must match exactly one schema in oneOf を生みます。
5. if/then/else と判別フィールド:条件を決定可能にする
条件付き制約は、決定経路が明示的なので oneOf より良いエラーを生みやすいです。if が通れば then が適用され、そうでなければ else が適用されます。
{
"type": "object",
"required": ["kind"],
"properties": { "kind": { "enum": ["text", "image"] } },
"if": {
"properties": { "kind": { "const": "image" } },
"required": ["kind"]
},
"then": { "required": ["url", "width", "height"] },
"else": { "required": ["content"] }
}
意図どおりに動くかを決める三つの詳細:
if内のpropertiesエントリは、キーが欠けても失敗しません。「kind が存在し image に等しい」を表すにはrequiredと組み合わせます。if自体は検証エラーを生みません。失敗すれば単にelseに分岐するので、それだけでは不正データを止められません。thenやelseが単独では無効です。ifの隣でしか適用されません。
ユニオン分岐が増えると、条件の連鎖は保守不能なネストになります。より堅牢なパターンは、各分岐内でタグフィールドを固定し、分岐を構造的に相互排他にすることです。
{
"oneOf": [
{
"properties": { "kind": { "const": "card" } },
"required": ["kind", "cardNumber"]
},
{
"properties": { "kind": { "const": "account" } },
"required": ["kind", "accountId"]
}
]
}
discriminator は OpenAPI 3.x の拡張で、JSON Schema のキーワードではありません。Ajv は discriminator: true オプションを有効にすると対応しますが、そのマッピングは mapping にあり、ツールチェーンをまたいで移植できません。スキーマが複数のバリデータで動く必要があるなら、拡張に一切依存しない const タグのパターンを優先してください。
6. 再帰参照:木、連結リスト、相互参照するファイル
再帰構造に特別な構文は不要です。自分自身への $ref で十分です。バリデータはコンパイル時に参照を無限に展開せず、実際のデータの深さに応じて再帰するので、数十行で任意の高さの木を記述できます。
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://bbdrug.com/schemas/tree-node.json",
"$defs": {
"node": {
"type": "object",
"required": ["name"],
"properties": {
"name": { "type": "string", "minLength": 1 },
"children": {
"type": "array",
"items": { "$ref": "#/$defs/node" }
}
}
}
},
"$ref": "#/$defs/node"
}
事前に決めておく価値のある三つの境界:
- データの深さ:スキーマは制限を課しませんが、検証もシリアライズも深さに比例してスタックを消費します。公開 API は 8 階層などの業務上の制限を設けるべきです。さもないと悪意ある深いペイロードがスタックを溢れさせます。
- 相互参照:A が B を参照し B が A を参照するのは合法ですが、両方のスキーマを先に登録する必要があります。さもないと解決順が結果を決めます。
- null 許容分岐:再帰フィールドが
nullを取りうるなら、"type": ["object", "null"]と書くかoneOfで包みます。$refは null 許容性を継承しません。
7. ファイル間参照とバンドル:本番でスキーマを取得しない
ファイル間参照は、バリデータが $id で対象をネットワーク越しに取得するため、開発では「たまたま動く」ことがよくあります。配備時にネットワークポリシーを変えると、検証チェーン全体が壊れます。本番の規則は単純です。参照されるすべてのスキーマは、プロセス内に登録するか事前にバンドルしなければなりません。
import Ajv2020 from 'ajv/dist/2020';
import addressSchema from './address.json' with { type: 'json' };
import orderSchema from './order.json' with { type: 'json' };
const ajv = new Ajv2020({ allErrors: true, strict: true });
// 参照されるファイルを先に登録する。キーはその $id と一致しなければ、相対参照が解決されない
ajv.addSchema(addressSchema, 'https://bbdrug.com/schemas/address.json');
// エントリのスキーマをモジュールスコープで一度だけコンパイルする
export const validateOrder = ajv.compile(orderSchema);
order.json が $id: "https://bbdrug.com/schemas/order.json" を持つなら、その "$ref": "address.json" は https://bbdrug.com/schemas/address.json に解決され、登録したキーと一致します。一文字でも違えば can't resolve reference になります。
三つの実践的な方法はこう比較できます。
| 方法 | 方法 | 強み | コスト |
|---|---|---|---|
| 実行時登録 | ファイルごとに addSchema |
ビルド不要。ファイルは独立のまま | 登録順とキーが手動 |
| ビルド時バンドル | ref パーサーで一つのファイルにマージ | 実行時依存ゼロ | 成果物が大きくなり、差分が騒がしい |
| 非同期読み込み | loadSchema + compileAsync |
頻繁に変わるスキーマを扱える | ネットワークとキャッシュの懸念。重要経路では避ける |
フロントエンドプロジェクトではビルド時のバンドルが安全です。サーバーでは、すべての参照が解決することをテストで証明できれば登録で足ります。どちらでも、CI で compile を実行し、参照エラーが最初の本番リクエストではなくマージ前に表面化するようにしてください。
8. デバッグ順:解決、コンパイル、それから分岐の検査
$ref の問題はほぼすべて三段階で特定でき、順序が重要です。
| 症状 | 考えられる原因 | 修正 |
|---|---|---|
can't resolve reference ... from id ... |
$id の綴り間違い、相対基底の誤り、対象が未登録 |
$id と解決後の絶対 URI を出力し、登録キーと比較する |
schema with key or id already exists |
同じ $id を二度登録した、または compile を繰り返し呼んだ |
登録をモジュール初期化に移し、一度だけ実行する |
strict mode: unknown keyword: "definitions" |
draft-07 構文の混在 | $defs に改名する。strict モードを無効にしない |
データが二つの oneOf 分岐に一致 |
分岐の制約が重複し、タグフィールドがない | 各分岐に const を足すか、anyOf に切り替える |
$defs 内の制約が効かない |
どこからも参照されていない | 参照パスを確認する。$defs は単独では何も検証しない |
デバッグ時は message より先に schemaPath を読みます。失敗した分岐とキーワードを名指しし、英語の散文よりはるかに信頼できます。コンパイル済みバリデータはモジュールスコープでキャッシュし、リクエストごとに再コンパイルしないでください。スキーマ変更のたびに、合格ケースと失敗ケースの両方をカバーします。ハッピーパスだけのテストは何もテストしていません。バリデータの選択とエラーメッセージ設計は JSON Schema:基礎から本番まで、スキーマを型生成に組み込む方法はスキーマと TypeScript 型の双方向同期を参照してください。
9. よくある質問
$ref は type や description の隣に置けますか?
2020-12 では $ref を兄弟キーワードと並べられ、それらの兄弟は通常どおり評価されます。draft-07 では兄弟は無視され、$ref だけが適用されます。同じスキーマを複数のドラフトで使うときの最大の振る舞いの違いなので、移行前にバリデータが対応するドラフトを確認してください。
$defs と definitions のどちらを使うべきですか?
新規の作業には $defs を使ってください。2019-09 で登場しました。definitions は 2020-12 のキーワードではありません。$ref: "#/definitions/foo" のようなポインタは単なる JSON Pointer なので解決できますが、strict モードは未知のキーワードとして報告します。両方のスタイルを混ぜると、CI でしか爆発しない地雷を残します。
oneOf が正しく見えるデータを拒否するのはなぜですか?
データが二つの分岐を同時に満たすからです。oneOf はちょうど一つを要求し、オブジェクトの一部だけを制約する分岐は、もう一方の分岐のフィールドが欠けていても満たされます。各分岐に const タグを足して相互排他にしてください。
再帰参照は無限にループしますか?
いいえ。バリデータはコンパイル時に参照を展開せず、実際のデータ上で再帰します。本当のリスクは、スタックを溢れさせるほど深いデータと、相互参照するファイル間の誤った解決順です。再帰フィールドへの業務レベルの深さ制限は、どんなスキーマの小技より効果的です。
本番でリモートの $ref に依存できますか?
できません。リモート参照は検証の可用性をネットワークに縛ります。ドメイン変更、証明書の期限切れ、内部ネットワークの分離がすべてチェーンを壊します。予測可能な動作が必要なら、参照されるスキーマを成果物にバンドルするか、起動時に登録してください。
$defs と別ファイルはどう決めればよいですか?
二か所に現れる構造は一度コピーしても構いません。三か所目に現れたら $defs に昇格させます。他言語や他リポジトリが読む必要があるときだけ、独自の $id を持つ独立ファイルに値します。
$defs 内の制約が効かないのはなぜですか?
$defs は単なるコンテナで、どの $ref も指さないノードは検証に関与しません。$defs の先頭に参照の来ない required リストがあってもデータは通ります。それは仕様であり、バリデータのバグではありません。
additionalProperties: false を allOf の分岐内に置くべきですか?
いいえ。各分岐は独立に評価されるので、ある分岐が別の分岐の宣言したフィールドを追加分として扱います。2020-12 の方法は、未知フィールドの方針をトップレベルに移し、すべての分岐の後に一度だけ評価される unevaluatedProperties: false を使うことです。