JSON Toolkit

JSON Schema のエラーが意味不明なとき:Ajv のエラー位置とメッセージ管理

要点:バリデータは機械的なエラーを出し、ユーザーはどの入力欄が間違っているかを知りたいのです。Ajv の errors 配列、instancePath と schemaPath、グルーピングと文言を扱います。

上級約 10 分で読めます2026-10-31 公開2026-10-31 更新
Ajv 検証エラーの位置特定とメッセージ管理の図

要点:まず instancePath を読んで失敗したデータを特定し、次に schemaPath で発火した規則を特定しましょう。required と additionalProperties では、メッセージを選ぶ前に params から本当のキー名を取り出します。

バリデータの仕事はどの規則が失敗したかを伝えることです。それを「この入力欄が間違っている」に翻訳するのはあなたの仕事です。

1. errors 配列が実際に与えてくれるもの

Ajv は検証失敗時に例外を投げず、結果を validate.errors に残します。既定の設定では最初のエラーだけを保持し、完全な一覧を生むのは allErrors です。一つのエラーオブジェクトには重要な五つのフィールドがあり、それらを理解することが後続のすべての前提です。

フィールド 意味 例
instancePath 失敗した値への JSON Pointer /email
schemaPath スキーマ内の失敗したキーワードの位置 #/properties/email/format
keyword 違反した制約の名前 format
params その制約の機械可読なパラメータ { "format": "email" }
message バージョン間で変わる英語の散文 must match format "email"

実際のエラーはこうなります。

[
  {
    "instancePath": "/email",
    "schemaPath": "#/properties/email/format",
    "keyword": "format",
    "params": { "format": "email" },
    "message": "must match format \"email\""
  }
]

message は人間向けに書かれた散文であり、安定したインターフェースではないことに注意してください。Ajv はバージョン間で言い回しを変えることがあり、ローカライズされた製品には使えません。正規表現でパースしてはいけませんし、クライアントにそのまま返してもいけません。

2. instancePath と schemaPath は別の問いに答える

instancePath はデータのどの部分が失敗したかを、schemaPath はどの規則が発火したかを示します。二つの座標系で、対称ではありません。

  • instancePath は RFC 6901 の JSON Pointer です。空文字列はルートを、/items/0/price は最初の配列要素の price フィールドを意味します。
  • schemaPath は # で始まり、スキーマ文書内のキーワードを指します。$ref をまたぐと参照先スキーマの内部を指すので、どのファイル由来かを知るには $id が必要です。
  • / や ~ を含むキーはそれぞれ ~1 と ~0 にエスケープされます。表示前に戻さないと、UI に不可解な記号が見えます。

たとえば instancePath: "/email" と schemaPath: "#/properties/email/format" の組み合わせは、ルートオブジェクトの email フィールドが format チェックを通らなかったことを意味します。

最も一般的な罠は required と additionalProperties です。それらの instancePath は問題のキーではなく親オブジェクトを指します。

キーワード instancePath が指す先 本当のキーの所在
required 親オブジェクト(ルートでは空文字列) params.missingProperty
additionalProperties 親オブジェクト params.additionalProperty
type 失敗した値そのもの —
enum 失敗した値そのもの params.allowedValues

エラーをフォームフィールドにマッピングするときは、この二つのケースを先に扱います。さもないと「email がない」が「ルートオブジェクトのエラー」になり、ユーザーはどこに入力すればよいか分かりません。

3. allErrors と verbose:完全性とコスト

既定の allErrors: false は Ajv を最初の失敗で止めます。コマンドラインツールには問題ありませんが、フォームには災難です。ユーザーは一つのフィールドを直して送信し、次のエラーを見、フォームが通るまで三度繰り返します。

設定 振る舞い 利点 コスト
allErrors: false 最初のエラーだけ 最速、最小メモリ 送信の繰り返し
allErrors: true すべてのエラーを収集 すべての問題を一度に 複雑なスキーマでエラー数が爆発
verbose: true 各エラーに schema と data を付ける サーバー側の診断が楽 サイズが大幅に増加。外部に露出しない
messages: false message フィールドを落とす ペイロードが小さい 英語のデバッグ手がかりを失う

推奨の分担:サーバー検証では allErrors: true を有効にし、完全な配列を内部ログに保ち、公開レスポンスは許可されたフィールドだけを運ぶ。 verbose は調査中だけ有効にします。一致したスキーマ断片とユーザーのデータをすべてのエラーに埋め込むので、メモリを食い、機密値をログに漏らす可能性があります。

エラーの量もよくある問題です。失敗した anyOf 分岐は複数の子エラーと一つの要約エラーを出し、分岐の多い oneOf はさらに悪いです。配列をそのまま表示すると、ユーザーは一つのフィールドにほぼ重複した五つのメッセージを見ます。描画の前にグルーピングが必要です。

4. フィールドでエラーをグループ化する

グルーピングは三つのことをします。パスをフィールド名に戻し、required と additionalProperties のずれを補正し、同じフィールドの複数エラーを最も実行可能な一つのメッセージに統合します。

/** 表示可能な検証エラー */
interface FieldError {
  field: string;
  keyword: string;
  params: Record<string, unknown>;
}

/**
 * Ajv のエラーをフィールドごとにグループ化する。
 * @param errors Ajv が生成したエラー配列
 * @returns フィールドパスをキーとし、各値がそのエラーの一覧であるマップ
 */
function groupByField(errors: Array<Record<string, any>>): Record<string, FieldError[]> {
  const grouped: Record<string, FieldError[]> = {};

  for (const errorItem of errors) {
    const keyword: string = errorItem.keyword;
    let field: string = errorItem.instancePath;

    if (keyword === 'required') {
      const parentPath: string = errorItem.instancePath;
      field = `${parentPath}/${errorItem.params.missingProperty}`;
    } else if (keyword === 'additionalProperties') {
      field = `${errorItem.instancePath}/${errorItem.params.additionalProperty}`;
    }

    const normalizedField = field === '' ? '(root)' : field;
    (grouped[normalizedField] ??= []).push({
      field: normalizedField,
      keyword,
      params: errorItem.params ?? {},
    });
  }

  return grouped;
}

優先順位をつけてマージします。まず required(欠落)、次に type、次に enum/const、次に format/pattern、最後に長さと範囲です。フィールドごとに最優先のメッセージだけを表示し、残りはログに保ちます。理由は単純です。型が間違っているとき、長さのメッセージは雑音であり、型を直すことだけが意味のある次の一歩です。

oneOf と anyOf の要約エラーは通常、実行可能な情報を持ちません。描画時にスキップし、具体的なフィールドを指す分岐エラーだけを残してください。

5. カスタムメッセージ:プラグイン経路とテンプレート経路

Ajv はローカライズされた文言を同梱していないので、実践的な選択肢は二つです。

選択肢その一:ajv-errors プラグイン。 errorMessage キーワードを追加し、メッセージを制約の隣に住まわせます。

{
  "properties": {
    "email": {
      "type": "string",
      "format": "email",
      "errorMessage": {
        "format": "有効なメールアドレスを入力してください",
        "type": "メールアドレスは文字列でなければなりません"
      }
    }
  }
}

利点は文言がフィールドと一緒に運ばれ、スキーマがドキュメントを兼ねることです。欠点はテキストが一つの言語に固定され、ローカライズには複数のスキーマを保守することになる点です。プラグインは allErrors: true が設定されているときだけすべてのメッセージを置き換えるので、そのフラグを忘れるとカスタムと既定の英語が混ざります。

選択肢その二:キーワードからテンプレートへの対応表。 バリデータは keyword と params だけを提供し、アプリケーションが文言を生成します。

キーワード テンプレート 利用可能なパラメータ
required 必須フィールドがありません:{field} missingProperty
type 期待される型:{type} type
minLength {limit} 文字以上入力してください limit
maximum {limit} を超えてはなりません limit
format 形式が正しくありません:{format} format
enum 許可された選択肢のいずれかでなければなりません allowedValues
pattern 値が期待される形式に一致しません pattern(エコーしない)

選択肢二は、多言語と一貫した語調を必要とする製品に適します。検証と表示を分離し、翻訳をスキーマの外に保ち、フロントエンドが同じテンプレートを再利用できます。

6. ローカライズ:メッセージはバリデータのものではない

文言を言語パックに移すことがメッセージ管理の出発点です。言語パックはテンプレートとキーを保存し、フィールド名は決して保存しません。フィールドのラベルはフォーム設定や API 契約から来て、レンダリング時に組み合わされます。

/** Ajv のキーワードをキーとする検証メッセージテンプレート */
const VALIDATION_TEMPLATES: Record<string, (params: Record<string, any>) => string> = {
  required: (params) => `必須フィールドがありません:${params.missingProperty}`,
  type: (params) => `期待される型:${params.type}`,
  minLength: (params) => `${params.limit} 文字以上入力してください`,
  maximum: (params) => `${params.limit} を超えてはなりません`,
  format: () => '形式が正しくありません',
};

/**
 * ユーザー向けの検証メッセージを一つ組み立てる。
 * @param keyword 失敗した制約の名前
 * @param params 制約のパラメータ
 * @returns ローカライズされたメッセージテキスト
 */
function buildMessage(keyword: string, params: Record<string, any>): string {
  const template = VALIDATION_TEMPLATES[keyword];
  return template ? template(params) : '値が正しくありません';
}

ここで重要な三つの規則:

  1. 文を連結しない。 「メール形式が正しくありません」とその中国語版は語順を共有せず、文字列の組み立ては遅かれ早かれどちらかで意味不明になります。
  2. 複数形と助数詞は i18n ライブラリに任せる。 テンプレートに「文字」をハードコードしないでください。1 character と 3 characters は異なります。
  3. フィールドのラベルも翻訳する。 params.missingProperty は email ですが、UI は「メールアドレス」と言うべきです。その対応は言語パックではなくフォーム設定に保ちます。

7. 内部構造をクライアントに返さない

schemaPath、params、message を一緒に返すのは開発中は便利で、本番では情報漏洩です。allowedValues は内部の enum を、pattern は検証用正規表現を、schemaPath はファイル構成とフィールド命名を明かします。場合によっては攻撃者に弱点の地図を手渡すことになります。

公開レスポンスはちょうど三つを保ちます。フィールドパス(またはフォームのフィールド名)、安定したエラーコード、ユーザー向けテキストです。生の keyword の代わりに短く安定したコードを使い、後でバリデータを変える余地を残します。

{
  "type": "about:blank",
  "title": "リクエストの検証に失敗しました",
  "status": 422,
  "errors": [
    { "field": "email", "code": "invalid_format", "message": "有効なメールアドレスを入力してください" },
    { "field": "items[0].price", "code": "invalid_pattern", "message": "金額は小数ちょうど 2 桁でなければなりません" }
  ]
}

サーバーログは逆を持つべきです。完全なエラー配列、リクエスト ID、スキーマのバージョン。そうすれば調査で「どの規則がこれを止めたか」に答えられます。リクエストボディ全体をログに捨てるのは避け、失敗したパスとキーワードだけを保ってください。

8. フロントエンドの表示:フィールド強調からエディタのマーカーまで

エラーは最終的に UI に着地し、表示は文脈に依存します。

文脈 表示 重要な操作
フォーム フィールド下の赤いテキスト、最初のエラーにフォーカス パスをフォーム名にマップし、配列インデックスを保つ
一括テーブル取り込み 行番号、列名、要約、エクスポート可能 /items/3/price を 4 行目の price 列に変える
JSON エディタ インラインマーカーと問題パネル パーサーで JSON Pointer を行・列にマップする
API デバッグツール schemaPath を保つ構造化エラー一覧 開発者向けなので詳細は許容

実装上の注意は三つ。

  • フィールドごとに最初のエラーだけを表示し、残りは「さらに 2 件の問題」に畳む。フォームが赤で溺れないようにするためです。
  • 配列インデックスは特定用に保ち、ラベルでは落とす。 「4 行目の price」は /items/3/price より読みやすいです。
  • エディタの場面では位置マッピングが必要。 フィールド名が繰り返されると単純な文字列検索は信頼できません。パーサーが各ノードのオフセットを提供し、それを行と列に変換します。

JSON がフォームではなくユーザーの入力から来るなら、まず文書が文法的に正しいことを確認してください。構文エラーはパース時に起こり、スキーマ検証には決して到達しません。JSON パースと整形が正確な行と列を報告し、スキーマのエラーを追うよりずっと速いです。サンプルから推論したときに厳しすぎるスキーマが生まれる仕組みは自動生成した JSON Schema が実データを拒否し続ける理由、バリデータの設定とキーワード体系は JSON Schema:基礎から本番までを参照してください。

9. よくある質問

エラーが一つしか返らないのはなぜですか?

allErrors は既定で false なので、Ajv は最初の失敗で返します。フォーム検証では明示的に有効にしてください。さもないとユーザーはすべての問題を知るために何度も送信しなければなりません。

required エラーのパスが空なのはなぜですか?

required の instancePath は親オブジェクトを指し、ルートでは空文字列です。欠落キー名は params.missingProperty にあります。二つを連結して完全なパスを得ます。instancePath だけを表示すると、無意味な「ルートオブジェクトのエラー」になります。

message をそのままユーザーに見せられますか?

おすすめしません。バージョン間で言い回しが変わる英語の散文で、ローカライズ能力もありません。keyword と params からテキストを生成し、message はログ用に取っておいてください。

カスタムエラーメッセージが無視されるのはなぜですか?

まず allErrors: true が有効か確認してください。ajv-errors はそのフラグがあるときだけすべてのメッセージを置き換えられます。次に errorMessage が失敗したキーワードと同じ階層にあるか確認してください。一段ずれると認識されません。

本番で返して安全なフィールドはどれですか?

フィールドパス、安定したエラーコード、ユーザー向けテキストです。schemaPath、params、message はサーバーログに保ちます。そうすれば内部の enum、正規表現、ファイル構成を明かさずにデバッグできます。