JSON Toolkit

JSON Schema:基礎から本番まで

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

中級約 14 分で読めます2026-10-30 公開2026-10-30 更新
JSON Schema:基礎から本番までのカバー画像

要点:まず $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。

頻出する三つの誤解:

  1. required はキーの存在だけを要求する:{"name": ""} は有効です。空文字列を拒否するには minLength: 1 を足さなければなりません。
  2. additionalProperties: false は互換性問題を拡大する:クライアントからの余分なフィールド一つでペイロード全体が失敗し、API の進化がすべての呼び出し元の同時アップグレードを強制します。
  3. 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 の判別フィールドを足してください。

参考資料

関連記事

スキーマと TypeScript 型の双方向同期

型とスキーマはそれぞれ一つの区間を担当します。一方はコンパイル時、もう一方は実行時です。問いはどちらを選ぶかではなく、二つをどう自動で同期させるかです。

中級約 11 分で読めます2026-10-30 更新

JSON API データ契約設計の要点

出荷後に変えるのが非常に難しい六つ。レスポンスエンベロープ、エラー形式、ページネーションの形、ID と時刻の表現、null と欠落の意味論、バージョン進化です。

中級約 13 分で読めます2026-10-30 更新

additionalProperties を閉じないと、Schema 検証は検証になっていない

検証を通過してもデータが clean とは限りません。Pydantic は未知フィールドを無視し、Zod は剥離し、Ajv は素通し—— hallucination された引数やタイプミスのキーが静かに通り抜けます。本稿は沈黙の受入れの3形態を示し、オブジェクトを閉じ、強制変換を制御し、ネスト全層で締める完全なレシピを提供します。

Intermediate約 8 分で読めます2026-11-02 更新