JSON Schema のエラーが意味不明なとき:Ajv のエラー位置とメッセージ管理
要点:バリデータは機械的なエラーを出し、ユーザーはどの入力欄が間違っているかを知りたいのです。Ajv の errors 配列、instancePath と schemaPath、グルーピングと文言を扱います。

要点:まず 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) : '値が正しくありません';
}
ここで重要な三つの規則:
- 文を連結しない。 「メール形式が正しくありません」とその中国語版は語順を共有せず、文字列の組み立ては遅かれ早かれどちらかで意味不明になります。
- 複数形と助数詞は i18n ライブラリに任せる。 テンプレートに「文字」をハードコードしないでください。
1 characterと3 charactersは異なります。 - フィールドのラベルも翻訳する。
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、正規表現、ファイル構成を明かさずにデバッグできます。