🐣

Zodのz.treeifyError()を調べてみた

に公開

Zodz.treeifyError() を調べる機会があったため、調べた結果をここにまとめます。

バージョン

zod@4.2.1

題材

APIのクエリーパラメーター price を題材とします。以下の要件があります。

  • 値と単位をスラッシュ区切りで持つ文字列とする。100/円 など
  • 単一指定も複数指定も可能とする。複数指定の場合は price=100/円&price=200/円 などと指定される。型は string | string[] となる
  • Zodスキーマ内で文字列のpriceを {value: number; unit: string;} に変換する
  • エラーが発生した場合はフロントエンドの入力フィールドvalue、unitの下にメッセージを表示する

これの実現方法として次の3パターンを思いつきましたが、それぞれのスキーマでエラーが発生した場合の z.treeifyError() の戻り値が微妙に異なったため、スキーマとエラー構造を書き記します。

パターン1: transformでデータ構造を変えた後に検査する場合

おそらくこのパターンは最も直感的なエラー構造になります。

// schema.ts
const price = z
  .string()
  // transform では検査はせずに変換のみ行う
  .transform((arg) => {
    const [valueStr, unit] = arg.split('/');
    const value = Number(valueStr);
    
    return {
      value,
      unit,
    };
  })
  .pipe(
    z.object({
      // ここで検査
      value: z.number().min(0),
      unit: z.string(),
    })
  );
const schema = z.object({
  price: z.union([price, z.array(price)]),
});

パターン1-1: 値が単一の文字列の場合

// schema.test.ts
const result = schema.safeParse({
  price: '-100/円', // 0 未満なためエラーとなる
});

if (!result.success) {
  const error = z.treeifyError(result.error);
  console.dir(error, { depth: 10 });
}
// エラー
{
  errors: [],
  properties: {
    price: {
      errors: [],
      // price.value に対してエラーが発生している
      properties: { value: { errors: [ 'Too small: expected number to be >=0' ] } }
    }
  }
}

パターン1-2: 値が配列の場合

// schema.test.ts
const result = schema.safeParse({
  price: ['100/円', '-100/円'], // 1 番目は 0 未満なためエラーとなる
});

if (!result.success) {
  const error = z.treeifyError(result.error);
  console.dir(error, { depth: 10 });
}
// エラー
{
  errors: [],
  properties: {
    price: {
      errors: [],
      items: [
        // 0 番目は正常なためエラーとならない
        undefined,
        {
          errors: [],
          // price.value に対してエラーが発生している
          properties: {
            value: { errors: [ 'Too small: expected number to be >=0' ] }
          }
        }
      ]
    }
  }
}

0番目のデータはエラーとならないため、items[0]undefined となるのは納得です。ただ、型情報を見ると items の要素は次のようになっており undefined となることはありません。Zodのバグなのかもしれません。Issue を作成しました。

{
  errors: string[];
  properties?:
    | {
        value?:
          | {
              errors: string[];
            }
          | undefined;
        unit?:
          | {
              errors: string[];
            }
          | undefined;
      }
    | undefined;
}

パターン2: transform内で検査する場合(pathを利用しない場合)

この場合はパターン1と異なり price.value に対してではなく price に対してエラーが設定されます。また z.union() に起因する期待しないエラーが発生してしまっています。

// schema.ts
const price = z.string().transform((arg, ctx) => {
  const [valueStr, unit] = arg.split('/');
  const value = Number(valueStr);

  // transform 内で検査
  if (value < 0) {
    ctx.addIssue({
      code: 'custom',
      message: 'Too small: expected number to be >=0',
      // path は利用しない
    });
  }
  
  return {
    value,
    unit,
  };
});
const schema = z.object({
  price: z.union([price, z.array(price)]),
});

パターン2-1: 値が単一の文字列の場合

// schema.test.ts
const result = schema.safeParse({
  price: '-100/円', // 0 未満なためエラーとなる
});

if (!result.success) {
  const error = z.treeifyError(result.error);
  console.dir(error, { depth: 10 });
}
// エラー
{
  errors: [],
  properties: {
    price: {
      errors: [
        // price.value ではなく price に対してエラーが発生している
        'Too small: expected number to be >=0',
        // z.union() は全てのスキーマを試すため `z.array(price)` のエラーが発生している
        'Invalid input: expected array, received string'
      ]
    }
  }
}

パターン2-2: 値が配列の場合

// schema.test.ts
const result = schema.safeParse({
  price: ['100/円', '-100/円'], // 1 番目は 0 未満なためエラーとなる
});

if (!result.success) {
  const error = z.treeifyError(result.error);
  console.dir(error, { depth: 10 });
}
// エラー
{
  errors: [],
  properties: {
    price: {
      // z.union() は全てのスキーマを試すため `price` のエラーが発生している
      errors: [ 'Invalid input: expected string, received array' ],
      items: [
        // 0 番目は正常なためエラーとならない
        undefined,
        // price.value ではなく price に対してエラーが発生している
        { errors: [ 'Too small: expected number to be >=0' ] }
      ]
    }
  }
}

パターン3: transform内で検査する場合(pathを利用する場合)

この場合はパターン1と同じく price.value に対するエラーが設定されます。また、パターン2と同じく z.union() に起因する期待しないエラーが発生してしまっています。

// schema.ts
const price = z.string().transform((arg, ctx) => {
  const [valueStr, unit] = arg.split('/');
  const value = Number(valueStr);
  
  // transform 内で検査
  if (value < 0) {
    ctx.addIssue({
      code: 'custom',
      message: 'Too small: expected number to be >=0',
      // path を利用
      path: ['value'],
    });
  }
  return {
    value,
    unit,
  };
});
const schema = z.object({
  price: z.union([price, z.array(price)]),
});

パターン3-1: 値が単一の文字列の場合

// schema.test.ts
const result = schema.safeParse({
  price: '-100/円', // 0 未満なためエラーとなる
});

if (!result.success) {
  const error = z.treeifyError(result.error);
  console.dir(error, { depth: 10 });
}
// エラー
{
  errors: [],
  properties: {
    price: {
      // z.union() は全てのスキーマを試すため `z.array(price)` のエラーが発生している
      errors: [ 'Invalid input: expected array, received string' ],
      // price.value に対してエラーが発生している
      properties: { value: { errors: [ 'Too small: expected number to be >=0' ] } }
    }
  }
}

パターン3-2: 値が配列の場合

// schema.test.ts
const result = schema.safeParse({
  price: ['100/円', '-100/円'], // 1 番目は 0 未満なためエラーとなる
});

if (!result.success) {
  const error = z.treeifyError(result.error);
  console.dir(error, { depth: 10 });
}
// エラー
{
  errors: [],
  properties: {
    price: {
      // z.union() は全てのスキーマを試すため `price` のエラーが発生している
      errors: [ 'Invalid input: expected string, received array' ],
      items: [
        // 0 番目は正常なためエラーとならない
        undefined,
        {
          errors: [],
          // price.value に対してエラーが発生している
          properties: {
            value: { errors: [ 'Too small: expected number to be >=0' ] }
          }
        }
      ]
    }
  }
}

比較

項目 パターン1 パターン2 パターン3
エラーを持つプロパティ z.infer<> ベース z.input<> ベース pathの指定に従う
z.union() 由来のエラー 出ない 出てしまう 出てしまう

で、結局どれがいいの?

今回の前提だとパターン1がよさそうです。ただし、以下の課題はあります。

  • フロントエンドから見ると「クエリーパラメーターpriceにエラーが発生した場合に、valueやunitという見慣れないプロパティのエラーが返却された」ことになります。フロントエンド、バックエンドで担当が異なる場合などは混乱を招くかもしれません
  • 今回は実装していませんがpriceにスラッシュが含まれていない場合エラーにしたいかもしれません。このときエラーはpriceに対して発生することになります(valuのエラーともunitのエラーとも言えないため)。そのため、このエラーをどこに表示するのかが課題になります

もしもフロントエンドの入力フィールドがvalue、unitと別れておらず1つであればパターン2がいいと思います。この場合はフロントエンドが把握しているpriceに対してエラーが返却されますし、priceにスラッシュが含まれていないエラーも他のエラーと同じように扱えます。ただし、valueにエラーがあるのかunitにエラーがあるのかわかりやすいメッセージにする必要があります。

コード

https://stackblitz.com/edit/vitejs-vite-td94uyv5?file=test%2Fschema.test.ts で試して頂けます。

Discussion