📝

Zodのエラーハンドリングについて覚書

2022/12/29に公開約14,000字

はじめに

Zodのエラーまわりについて調べたことの覚書です。

https://zod.dev/ERROR_HANDLING?id=error-handling-in-zod

parseとエラー

Zodはparseメソッドでスキーマに定義したバリデーションを実行します。不正があった場合はZodErrorをスローします。

safeparseを使うとスローエラーをせずにオブジェクト形式で結果を受け取ることができます。

import { z } from "zod";

// creating a schema for strings
const mySchema = z.string();

// parsing
mySchema.parse("tuna"); // => "tuna"
mySchema.parse(12); // => throws ZodError

// "safe" parsing (doesn't throw error if validation fails)
mySchema.safeParse("tuna"); // => { success: true; data: "tuna" }
mySchema.safeParse(12); // => { success: false; error: ZodError }

ZodError

ZodError

  • 全てのバリデーションエラーはZodErrorのインスタンスを返します
  • ZodErrorErrorを継承しています
  • ZodErrorは,ZodIssueの配列であるissuesプロパティを持っています。 各イシューには検証中に発生した内容が記されています。
class ZodError extends Error {
  issues: ZodIssue[];
}

https://github.com/colinhacks/zod/blob/master/src/ZodError.ts#L184-L301

独自のエラーインスタンスを作成できます

import * as z from "zod";

const myError = new z.ZodError([]);

ZodIssue

ZodIssue

  • ZodErrorが持つ各エラーの詳細情報
  • ZodIssueはclassではなくdiscriminated union[1]です。

基本的なプロパティ

  • code
    • イシューコード
  • path
    • エラーの項目 e.g, ['addresses', 0, 'line1']
  • message
    • エラーメッセージ

ただし、エラーによっては追加のプロパティも存在する場合があります。
エラーコードの一覧とそれぞれ持っているプロパティはドキュメントの表に詳しく載っています。

実際の型定義

ZodIssueを見てみるとZodIssueOptionalMessageに対してfatalmessageを拡張した型になっています。
https://github.com/colinhacks/zod/blob/b9db3e75211a353af01d181a58a541f5b97fefb3/deno/lib/ZodError.ts#L159-L162

ZodIssueOptionalMessageを見てみると以下のようになっています。デフォルトのイシューが定義されています。
https://github.com/colinhacks/zod/blob/b9db3e75211a353af01d181a58a541f5b97fefb3/deno/lib/ZodError.ts#L141-L157

さらに個々のイシューの定義を見てみると以下のようのになっています。 イシューはZodIssueBaseを継承し、codeとそのイシュー固有のプロパティが拡張されています。
https://github.com/colinhacks/zod/blob/b9db3e75211a353af01d181a58a541f5b97fefb3/deno/lib/ZodError.ts#L44-L48

ZodIssueBaseにはpathmessageが定義されています。
https://github.com/colinhacks/zod/blob/b9db3e75211a353af01d181a58a541f5b97fefb3/deno/lib/ZodError.ts#L39-L42

Example

a-demonstrative-example

以下のようなスキーマを定義します。

const person = z.object({
  names: z.array(z.string()).nonempty(), // at least 1 name
  address: z.object({
    line1: z.string(),
    zipCode: z.number().min(10000), // American 5-digit code
  }),
});

不正なデータをパースさせます。

try {
  person.parse({
    names: ["Dave", 12], // 12 is not a string
    address: {
      line1: "123 Maple Ave",
      zipCode: 123, // zip code isn't 5 digits
      extra: "other stuff", // unrecognized key
    },
  });
} catch (err) {
  if (err instanceof z.ZodError) {
    console.log(err.issues);
  }
}

ZodErrorがスローされ、以下のようにissuesにエラーの内容が格納されています。

[
  {
    code: "invalid_type",
    expected: "string",
    received: "number",
    path: ["names", 1],
    message: "Invalid input: expected string, received number",
  },
  {
    code: "unrecognized_keys",
    keys: ["extra"],
    path: ["address"],
    message: "Unrecognized key(s) in object: 'extra'",
  },
  {
    code: "too_small",
    minimum: 10000,
    type: "number",
    inclusive: true,
    path: ["address", "zipCode"],
    message: "Value should be greater than or equal to 10000",
  },
];

エラーマップ

zoderrormap

zodではエラーマップによりデフォルトでエラーメッセージが定義されています。

https://github.com/colinhacks/zod/blob/master/src/locales/en.ts#L4-L134

ZodErrorMapで独自の定義を作り、それをsetErrorMapでセットすることでエラーメッセージをカスタマイズできます。

import { z } from "zod";

const customErrorMap: z.ZodErrorMap = (issue, ctx) => {
  if (issue.code === z.ZodIssueCode.invalid_type) {
    if (issue.expected === "string") {
      return { message: "bad type!" };
    }
  }
  if (issue.code === z.ZodIssueCode.custom) {
    return { message: `less-than-${(issue.params || {}).minimum}` };
  }
  return { message: ctx.defaultError };
};

z.setErrorMap(customErrorMap);

ZodErrorMapissuectxの2つの引数を取り、{ message: string }を返します。

https://github.com/colinhacks/zod/blob/master/src/ZodError.ts#L312-L320

  • issue: バリデーションで不正だった項目の情報
  • ctx
    • defaultError: デフォルトのエラーマップによって生成されるエラーメッセージ
    • data: .parseに渡されたデータ

上記のサンプルの場合は、ZodIssueCode.invalid_typeZodIssueCode.customのエラーメッセージを変更し、それ以外はctx.defaultErrorの内容を返します。

エラーマップの優先順位

error-map-priority

エラーマップをカスタマイズするには3つの方法があり、優先順位は以下のようになっています。
Global error map < Schema-bound error map < Contextual error map

Global error map

z.setErrorMapでグローバルエラーマップをカスタマイズできます(優先順位が一番低い)

const myErrorMap: z.ZodErrorMap = /* ... */;
z.setErrorMap(errorMap);

https://github.com/colinhacks/zod/blob/master/src/errors.ts#L7-L9

サンプル
/**
 * グローバルマップのカスタマイズ
 */
const errorMap: z.ZodErrorMap = (error, ctx) => {
  switch (error.code) {
    case z.ZodIssueCode.invalid_type:
      if (error.expected === 'string') {
        return { message: `Global error map` };
      }
      break;
  }
  return { message: ctx.defaultError };
};
z.setErrorMap(errorMap);

// スキーマ定義
export const FormData = z.object({
  name: z.string(),
  contactInfo: z.object({
    email: z.string().email(),
    phone: z.string().optional(),
  }),
});

const sampleData = {
  name: null,
  contactInfo: {
    email: 'not an email',
    phone: '867-5309',
  },
};

// バリデーション実行
const parsed = FormData.safeParse(sampleData);
const { error } = parsed as any;

console.log('issues:', (error as any).issues);

実行結果

console.log
    issues: [
      {
        code: 'invalid_type',
        expected: 'string',
        received: 'null',
        path: [ 'name' ],
        message: 'Global error map' // 上書きされている
      },
      {
        validation: 'email',
        code: 'invalid_string',
        message: 'Invalid email',
        path: [ 'contactInfo', 'email' ]
      }
    ]

Schema-bound error map

個別のスキーマでカスタマイズする。Global error mapより優先されます。

z.string({ errorMap: myErrorMap });

// this creates an error map under the hood
z.string({
  invalid_type_error: "Invalid name",
  required_error: "Name is required",
});
サンプル
/**
 * グローバルマップのカスタマイズ
 */
const errorMap: z.ZodErrorMap = (error, ctx) => {
  switch (error.code) {
    case z.ZodIssueCode.invalid_type:
      if (error.expected === 'string') {
        return { message: `Global error map` };
      }
      break;
  }
  return { message: ctx.defaultError };
};
z.setErrorMap(errorMap);

// スキーマ定義
const FormData = z.object({
  name: z.string({
    /**
     * 定義時にエラーマップをカスタマイズ
     */
    errorMap: (error, ctx) => {
      switch (error.code) {
        case z.ZodIssueCode.invalid_type:
          if (error.expected === 'string') {
            return { message: `Schema-bound error map` };
          }
          break;
      }
      return { message: ctx.defaultError };
    },
  }),
  contactInfo: z.object({
    email: z.string().email(),
    phone: z.string().optional(),
  }),
});

const sampleData = {
  name: null,
  contactInfo: {
    email: 'not an email',
    phone: '867-5309',
  },
};

// バリデーション実行
const parsed = FormData.safeParse(sampleData);
const { error } = parsed as any;

console.log('issues:', (error as any).issues);

実行結果

console.log
    issues: [
      {
        code: 'invalid_type',
        expected: 'string',
        received: 'null',
        path: [ 'name' ],
        message: 'Schema-bound error map' // 上書きされている
      },
      {
        validation: 'email',
        code: 'invalid_string',
        message: 'Invalid email',
        path: [ 'contactInfo', 'email' ]
      }
    ]

Contextual error map

parseメソッドのパラメーターとしてエラーマップを渡せます。 このエラーマップが提供されている場合は、最も優先度が高くなります。

z.string().parse("adsf", { errorMap: myErrorMap });
サンプル
/**
 * グローバルマップのカスタマイズ
 */
const errorMap: z.ZodErrorMap = (error, ctx) => {
  switch (error.code) {
    case z.ZodIssueCode.invalid_type:
      if (error.expected === 'string') {
        return { message: `Global error map` };
      }
      break;
  }
  return { message: ctx.defaultError };
};
z.setErrorMap(errorMap);

// スキーマ定義
const FormData = z.object({
  name: z.string({
    /**
     * 定義時にエラーマップをカスタマイズ
     */
    errorMap: (error, ctx) => {
      switch (error.code) {
        case z.ZodIssueCode.invalid_type:
          if (error.expected === 'string') {
            return { message: `Schema-bound error map` };
          }
          break;
      }
      return { message: ctx.defaultError };
    },
  }),
  contactInfo: z.object({
    email: z.string().email(),
    phone: z.string().optional(),
  }),
});

const sampleData = {
  name: null,
  contactInfo: {
    email: 'not an email',
    phone: '867-5309',
  },
};

// バリデーション実行
const parsed = FormData.safeParse(sampleData, {
  /**
   * 実行時にエラーマップをカスタマイズ
   */
  errorMap: (error, ctx) => {
    switch (error.code) {
      case z.ZodIssueCode.invalid_type:
        if (error.expected === 'string') {
          return { message: `Contextual error map` };
        }
        break;
    }
    return { message: ctx.defaultError };
  },
});
const { error } = parsed as any;

console.log('issues:', (error as any).issues);

実行結果

console.log
    issues: [
      {
        code: 'invalid_type',
        expected: 'string',
        received: 'null',
        path: [ 'name' ],
        message: 'Contextual error map' // 上書きされている
      },
      {
        validation: 'email',
        code: 'invalid_string',
        message: 'Invalid email',
        path: [ 'contactInfo', 'email' ]
      }
    ]

エラーのフォーマット機能

error-handling-for-forms

ZodErrorの構造をフォームで使いやすくするため以下のメソッドが用意されています。

  • .format()
  • .flatten()

項目の内容が不正の場合

例えば以下のようなスキーマを定義します。

const FormData = z.object({
  name: z.string(),
  contactInfo: z.object({
    email: z.string().email(),
    phone: z.string().optional(),
  }),
});

以下のように各項目に間違ったデータを入れて実行してみます。

const sampleData = {
  name: null,
  contactInfo: {
    email: 'not an email',
    phone: '867-5309',
  },
};
const parsed = FormData.safeParse(sampleData);
const { error } = parsed as any;

console.log('issues:', (error as any).issues);
console.log('format: ', error.format());
console.log('flatten: ', error.flatten());

実行結果は以下のようになります。

  console.log
    issues: [
      {
        code: 'invalid_type',
        expected: 'string',
        received: 'null',
        path: [ 'name' ],
        message: 'Expected string, received null'
      },
      {
        validation: 'email',
        code: 'invalid_string',
        message: 'Invalid email',
        path: [ 'contactInfo', 'email' ]
      }
    ]

  console.log
    format:  {
      _errors: [],
      name: { _errors: [ 'Expected string, received null' ] },
      contactInfo: { _errors: [], email: { _errors: [Array] } }
    }
    
  console.log
    flatten:  {
      formErrors: [],
      fieldErrors: {
        name: [ 'Expected string, received null' ],
        contactInfo: [ 'Invalid email' ]
      }
    }

.format()のほうは_errorsが空になっていて、各項目名がキーになったオブジェクトのほうにエラー内容が入っています。

.flatten()のほうはformErrorsが空になっていて、fieldErrorsの中に各項目名がキーになったオブジェクトが入っています。

ルートレベルで不正なデータの場合

次にデータを null にして実行してみます。

const sampleData = null;
const parsed = FormData.safeParse(sampleData);
const { error } = parsed as any;

console.log('issues:', (error as any).issues);
console.log('format: ', error.format());
console.log('flatten: ', error.flatten());

実行結果は以下のようになります。

 console.log
    issues: [
      {
        code: 'invalid_type',
        expected: 'object',
        received: 'null',
        path: [],
        message: 'Expected object, received null'
      }
    ]

  console.log
    format:  { _errors: [ 'Expected object, received null' ] }

  console.log
    flatten:  { formErrors: [ 'Expected object, received null' ], fieldErrors: {} }

.format()はルートレベルのエラーの場合_errorsにエラーの内容が記されます。

.flatten()はルートレベルのエラーの場合formErrorsにエラーの内容が記されてます。この場合fieldErrorsは空になっています。

フォーマットのカスタマイズ

.flatten().format()はどちらも、マッピング関数 (issue: ZodIssue) => U to flatten()を渡すことで各ZodIssueが最終出力でどのように変換されるかをカスタマイズできます。

result.error.flatten((issue: ZodIssue) => ({
  message: issue.message,
  errorCode: issue.code,
}));
/*
  {
    formErrors: [],
    fieldErrors: {
      name: [
        {message: "Expected string, received null", errorCode: "invalid_type"}
      ]
      contactInfo: [
        {message: "Invalid email", errorCode: "invalid_string"}
      ]
    },
  }
*/

型シグネチャの抽出

z.inferFormattedErrorz.inferFlattenedErrorsを使用して、.format().flatten()の戻り値の型シグネチャを取得できます。

type FormattedErrors = z.inferFormattedError<typeof FormData>;
/*
  {  
    name?: {_errors?: string[]},
    contactInfo?: {
      _errors?: string[],
      email?: {
        _errors?: string[],
      },
      phone?: {
        _errors?: string[],
      },
    },
  } 
*/

type FlattenedErrors = z.inferFlattenedErrors<typeof FormData>;
/*
  {  
    formErrors: string[],
    fieldErrors: {
      email?: string[],
      password?: string[],
      confirm?: string[]
    } 
  }
*/

第2引数でカスタマイズしたフォーマットの型定義を指定できます。

type FormDataErrors = z.inferFlattenedErrors<
  typeof FormData,
  { message: string; errorCode: string }
>;

/*
  { 
    formErrors: { message: string, errorCode: string }[],
    fieldErrors: {
      email?: { message: string, errorCode: string }[],
      password?: { message: string, errorCode: string }[],
      confirm?: { message: string, errorCode: string }[]
    }
  }
*/

備考

https://github.com/t-shiratori/hello-zod

脚注
  1. 判別可能なユニオン型 (discriminated union) | TypeScript入門『サバイバルTypeScript』 ↩︎

Discussion

ログインするとコメントできます