JSON Schema:基礎から本番まで
要点:キーワードは多くありますが、日常的に使うのは十数個だけです。type/properties/required/$ref を正しく押さえ、エラーメッセージを適切に扱えば、実務の八割をカバーできます。

要点:まず $schema を 2020-12 に固定し、$defs + $ref で共通構造を抽出しましょう。oneOf はちょうど一つの分岐が一致しなければならない場合にだけ使い、それ以外では判別フィールドか anyOf を優先します。
スキーマはドキュメントの付録ではなく、実行し、テストし、生成できる資産です。
1. まずバージョンを正しく理解する
| バージョン | $schema の値 |
状態 | 備考 |
|---|---|---|---|
| draft-04 | http://json-schema.org/draft-04/schema# |
古い | レガシープロジェクトと一部の OpenAPI 2.0 ツール |
| draft-07 | http://json-schema.org/draft-07/schema# |
広く使われる | OpenAPI 3.0 の事実上の基盤 |
| 2019-09 | https://json-schema.org/draft/2019-09/schema |
利用可能 | $defs と注釈語彙を導入 |
| 2020-12 | https://json-schema.org/draft/2020-12/schema |
現在推奨 | prefixItems を導入し、items の意味を変更 |
新規プロジェクトには 2020-12 を使います。2020-12 では items が「残りの要素」だけを制約し、タプル形式の宣言は prefixItems に移った点に注意してください。
draft-07 から移行するときの最も一般的な罠です。
2. 最小限の実用スキーマ
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://bbdrug.com/schemas/user.json",
"type": "object",
"required": ["id", "name"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"name": { "type": "string", "minLength": 1, "maxLength": 64 },
"age": { "type": "integer", "minimum": 0, "maximum": 150 },
"tags": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }
},
"additionalProperties": false
}
このスキーマは最も一般的な四つの関心事をカバーします。型制約、必須フィールド、範囲制限、配列要素の制約です。
3. キーワードマップ
3.1 汎用
| キーワード | 目的 | 注意点 |
|---|---|---|
type |
型。配列でもよい | "type": ["string", "null"] は nullable を意味する |
enum |
列挙値 | 値は一意でなければならない |
const |
単一値の制約 | バージョン番号のような固定値に適する |
$ref |
別のスキーマを参照 | スキーマ全体を参照し、参照側オブジェクトの兄弟キーワードとはマージされない |
3.2 数値
multipleOf、maximum、exclusiveMaximum、minimum、exclusiveMinimum。
draft-04 では exclusiveMaximum が真偽値(maximum と組み合わせて使う)だったことに注意してください。draft-07 以降にようやく独立した数値キーワードになりました。
「古いドキュメントからコピーしたスキーマ」が strict モードで失敗する原因です。
3.3 文字列
minLength、maxLength、pattern(ECMA-262 の正規表現)、format(注釈)。
minLength は Unicode コードポイントを数え、UTF-16 単位でもバイトでもありません。JavaScript の string.length で検証すると食い違います。
3.4 配列
| キーワード | 目的 |
|---|---|
prefixItems |
2020-12 のタプル形式制約。インデックスごとに要素を照合する |
items |
残りの要素に対する一律の制約(2020-12) |
contains + minContains/maxContains |
一致する要素が最低/最大いくつ必要か |
uniqueItems |
要素がすべて異なること(深い比較。大きな配列では避ける) |
minItems / maxItems |
長さの制約 |
3.5 オブジェクト
properties、required、patternProperties、additionalProperties、propertyNames、
minProperties、maxProperties、dependentRequired、dependentSchemas。
頻出する三つの誤解:
requiredはキーの存在だけを要求する:{"name": ""}は有効です。空文字列を拒否するにはminLength: 1を足さなければなりません。additionalProperties: falseは互換性問題を拡大する:クライアントからの余分なフィールド一つでペイロード全体が失敗し、API の進化がすべての呼び出し元の同時アップグレードを強制します。propertiesは宣言されていないキーを制限しない。既定では許可されます。
3.6 論理合成
{
"oneOf": [
{ "type": "object", "required": ["cardNumber"], "properties": { "cardNumber": { "type": "string" } } },
{ "type": "object", "required": ["accountId"], "properties": { "accountId": { "type": "string" } } }
]
}
| キーワード | 意味論 | 典型的な失敗 |
|---|---|---|
allOf |
すべてを満たす必要がある | 複数の分岐が同じフィールドに矛盾する制約を課す |
anyOf |
少なくとも一つを満たす | エラーメッセージを特定の分岐に辿るのが難しい |
oneOf |
ちょうど一つを満たす | データが二つの分岐に一致して失敗する。しかも「明らかに正しく見える」 |
not |
満たしてはならない | 使用は稀で、エラーメッセージが最も不親切 |
oneOf は「正しいデータなのに検証が失敗する」を最も生みやすいキーワードです。分岐の制約が重なる場合(たとえば両方が追加フィールドを許す場合)、
一つのレコードが複数の分岐に一致し、oneOf は即座に失敗します。その場合は anyOf に切り替えるか、分岐内で const を使う判別フィールドを使ってください。
{
"type": "object",
"required": ["kind"],
"properties": { "kind": { "enum": ["text", "image"] } },
"if": { "properties": { "kind": { "const": "image" } } },
"then": { "required": ["url", "width", "height"] },
"else": { "required": ["content"] }
}
判別フィールド方式は oneOf より読みやすく、エラーも良くなります。
4. 分割と再利用
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://bbdrug.com/schemas/order.json",
"$defs": {
"money": { "type": "string", "pattern": "^-?\\d+\\.\\d{2}$" },
"address": {
"type": "object",
"required": ["city"],
"properties": { "city": { "type": "string" }, "detail": { "type": "string" } }
}
},
"type": "object",
"required": ["total", "shipTo"],
"properties": {
"total": { "$ref": "#/$defs/money" },
"shipTo": { "$ref": "#/$defs/address" }
}
}
$defsは 2019-09 で導入された形式です。draft-07 ではdefinitionsを使います。- ファイル間参照は相対
$idを使いますが、本番でリモートスキーマの実行時取得に依存しないでください。静的ファイルとしてバンドルします。 - 参照の隣に制約を足すことはできません(兄弟キーワードに対する
$refの振る舞いはバージョンで異なります)。合成が必要なときはallOfを使います。
5. Ajv による実行時検証
import Ajv2020 from 'ajv/dist/2020';
import addFormats from 'ajv-formats';
import orderSchema from './order.json' with { type: 'json' };
const ajv = new Ajv2020({
allErrors: true,
strict: true,
allowUnionTypes: true,
});
addFormats(ajv);
const validateOrder = ajv.compile(orderSchema);
export function assertOrder(input: unknown): void {
if (validateOrder(input)) {
return;
}
const detail = (validateOrder.errors ?? []).map((errorItem) => ({
path: errorItem.instancePath || '/',
keyword: errorItem.keyword,
message: errorItem.message,
params: errorItem.params,
}));
throw new Error(`注文データの検証に失敗しました:${JSON.stringify(detail)}`);
}
重要な点:
compileは一度だけ、モジュールのトップレベルで呼びます。リクエストごとに再コンパイルすると、10 ms の検証が数百 ms になります。strict: trueはキーワードの綴り間違い(minLengthではなくminlengthなど)を早期に表面化させるので、有効にする価値があります。ajv-formatsを入れなければformat: "email"やformat: "date-time"は実際には効きません。- 2020-12 は
ajv/dist/2020のエントリポイントが必要です。既定のエクスポートは使わないでください。
6. エラーメッセージの設計
バリデータが返すのは「どのキーワードが失敗したか」で、ユーザーが知りたいのは「どの入力欄が間違っているか」です。マッピング層を足す価値があります。
const FIELD_LABEL: Record<string, string> = {
'/name': '名前',
'/age': '年齢',
'/tags': 'タグ',
};
/**
* Ajv のエラーを表示用メッセージに変換する。
* @param errors Ajv のエラー一覧
* @returns 表示メッセージの配列
*/
function toMessages(errors: Array<{ instancePath: string; keyword: string; params: unknown }>): string[] {
return errors.map((errorItem) => {
const label = FIELD_LABEL[errorItem.instancePath] ?? errorItem.instancePath;
if (errorItem.keyword === 'required') {
const missing = (errorItem.params as { missingProperty: string }).missingProperty;
return `${label} に必須フィールド ${missing} がありません`;
}
if (errorItem.keyword === 'minLength') {
return `${label} が短すぎます`;
}
return `${label} の検証に失敗しました(${errorItem.keyword})`;
});
}
API 側では、フロントエンドが表示方法を決められるよう、機械可読な構造を保ちます。
{
"type": "https://bbdrug.com/problems/validation",
"title": "リクエストデータの検証に失敗しました",
"status": 422,
"errors": [
{ "path": "/items/0/price", "keyword": "pattern", "message": "小数 2 桁の金額文字列でなければなりません" }
]
}
7. 性能と罠
- 検証コストはスキーマの複雑さとデータサイズに比例します。複数の
oneOf分岐は組み合わせ爆発を起こし、数千レコードでは非常に目立ちます。 uniqueItemsは配列に対して O(n²) の深い比較を行います。大きな配列では業務層で重複排除してください。patternに壊滅的なバックトラックを起こすパターン((a+)+$など)を避けます。ReDoS の典型的な入口です。additionalProperties: falseをallOfと組み合わせると、サブスキーマで宣言されたフィールドが親から「追加」と判定されます。一つのスキーマにマージするか、unevaluatedPropertiesを使ってください。
8. チェックリスト
- [ ] 2020-12 に固定し、ファイル先頭で
$schemaを宣言する。 - [ ] コピー&ペーストではなく
$defs+$refで共通構造を抽出する。 - [ ]
oneOfより判別フィールドを優先する。 - [ ] バリデータの
compile結果を再利用し、strict モードを有効にする。 - [ ]
formatが強制されるかどうかを明確に文書化する。 - [ ] エラー応答にパス、キーワード、ユーザー向けメッセージを提供する。
- [ ] スキーマを CI に入れる。JSON Schema Test Suite のケースを回帰テストとして実行する。
FAQ
JSON Schema のドラフトはどう選べばよいですか?
新規プロジェクトには 2020-12 を使い、ファイル先頭で $schema を宣言します。スキーマを OpenAPI 3.1 に入れるなら 2020-12 でなければなりません。3.1 はそのバージョンを直接埋め込むからです。draft-07 は古いツールチェーンに縛られたプロジェクトにだけ残します。items と $ref の兄弟キーワードの振る舞いが異なるので、一つのスキーマ群で混在させないでください。
additionalProperties: false が互換性問題を拡大するのはなぜですか?
宣言されていないすべてのキーをエラーにするので、クライアントからの余分なフィールド一つでペイロード全体が失敗します。API が進化するたびにすべての呼び出し元が同時にアップグレードするか、古いクライアントが拒否されることになります。設定系のスキーマは追加フィールドを明示的に閉じるべきですが、公開 API は互換性コストを秤にかける必要があります。
oneOf と anyOf のどちらを使うべきですか?
データがちょうど一つの分岐に一致しなければならない場合にだけ oneOf を使います。分岐の制約が同時に成り立ちうるなら、一つのレコードが複数の分岐に一致して oneOf は即座に失敗します。anyOf に切り替えるか、各分岐内に const の判別フィールドを足してください。