EY-Office ブログ

Zodを雰囲気で使っていたので勉強してリファクタリングした

みなさんはZodを使った事がありますか? ZodはTypeScript向けの型安全なスキーマ定義とバリデーションライブラリで、データ構造を簡単に定義し型と整合性のある検証が可能です。

以前ある仕事でZodを使ったのですが、判りにくいところがありネット上の情報とAIに頼りに雰囲気で使ってしまいました。出来たコードは今一つで気になっていたので、今回Zodを勉強してコードをリファクタリングしてみました。

study_zod Gemini 2.5 Flash Imageが生成した画像を使っています

以前書いたコード

以前書いたコードはReactで書かれたクライアントから受け取ったデータを、サーバー側で検証(バリデーション)しデータベースに書くコードでした。全体はそれなりの大きさがあるので、今回はバリデーション部分を簡単な関数にまとめてみました。

  • ⑤ バリデーションを行うencodeData関数
  • ② encodeData関数の引数の型、オブジェクトの全プロパティーは文字列です
  • ③ encodeData関数の戻り値、変換されたオブジェクト①とエラー文字列の配列です
  • ① encodeData関数で変換されたデータの型、プロパティーは日付、文字列、数値です
  • ④ Zodのスキーマ定義です、詳細は後で説明します
import { z } from 'zod';

export type DataType = {                // ← ①
  date: Date;
  item: string;
  amount: number
};

export type StringDataType = {         // ← ②
  date: string;
  item: string;
  amount: string;
  payingIn: string
};

export type ValidateResultType = {     // ← ③
  data: DataType;
  errors: string[];
}

const dataSchema = z.object({          // ← ④
  date: z.string().min(1, {message: "日付を入力して下さい"})
    .pipe(z.coerce.date({message: "正しい日付を入力して下さい"})),
  item: z.string().min(1, {message: "項目を入力して下さい"}),
  amount: z.string().min(1, {message: "金額を入力して下さい"}).transform(Number)
    .pipe(z.number({message: "正しい金額を入力して下さい"}).min(1, {message: "金額は1円以上で入力して下さい"})),
  payingIn: z.enum(["y", "n"], {message: "正しい入金・出金を入力して下さい"})
});
       // ↓ ⑤
const encodeData = (stringData: StringDataType) : ValidateResultType => {
  const validatedFields = dataSchema.safeParse(stringData);
  if (validatedFields.success) {
    return {
      data: validatedFields.data,
      errors: []
    };
  } else {
    return {
      data: { date: new Date(), item: "", amount: 0 },
      errors: validatedFields.error.issues.map(e => e.message)
    };
  }
}

export default encodeData;

Zodのスキーマ定義

Zodの解説記事ではないですが、Zodのキモであるスキーマ定義を簡単に説明します。

item

まずは簡単な2番目のitemから

  • このプロパティーは文字列なのでz.string()を使用
  • 入力が無い場合はエラーにしたいのでmin(1, ...)で文字列の長さが1より小さい(=入力なし)なら"項目を入力して下さい"エラー
payingIn

最後のpayingInは

  • 入力がyまたはnの選択肢なので z.enum([...])を使用
  • それ以外なら"正しい入金・出金を入力して下さい"エラー
date

dateは日付yyyy-mm-dd形式なので、単純にはz.date(...)で良いのです。しかし日付の形式が正しくないとき以外に、入直が無い場合も"正しい日付を入力して下さい"エラーになってしまいます。

そこで、ここでは

  • z.string().min(1, ...)で入力なしの場合"日付を入力して下さい"エラーにする
  • その後z.coerce.date()で日付データに変換します、coerceが付いていると形式チェックだけでなく変換も行います
  • .pipe(...)はスキーマ定義の連結で、このように変換する場合に使います
amount

これは複雑ですね

  • z.string().min(1, ...)で入力なしの場合"金額を入力して下さい"エラーにする
  • .transform(...)も変換で、coerceよりも柔軟な変換処理が行えます
    • なぜcoerceでは行けないのか、この時点では判っていませんでしたが、coerceではエラーになるのでAIの解答を採用しました😅
  • .pipe(z.number(...) ...)で数値に変換する処理を連結しています。数値形式でなければ"正しい金額を入力して下さい"エラー
  • その後.min(1, ...)で数値が1以下(0やマイナス)なら"金額は1円以上で入力して下さい"エラー

Zodの学習

Zodはvalidator.jsのような単純な形式チェックライブラリーではなく、データ構造を定義し、型と整合性のあるかの検証が可能なライブラリーです。しかし、少し複雑です。
公式ドキュメントもしっかりしているので、英語に問題ない方は公式ドキュメントを読めば学べます。

しかし・・・ という方は、まず 逆引き 型ファースト Zod を読むのが良いと思います。日本語で丁寧に解説してあります。ありがとうございます! 😃

ただし、最新版ではない事や入門書なので簡単な説明で済ましている部分もあるので、逆引き 型ファースト Zodを読んだ後、公式ドキュメントDefining schemasの気になる部分を読むと良いと思います。「逆引き 型ファースト Zod」で基本がわかっていれば英語のドキュメントでも、すんなり読めると思います。

リファクタリングしたコード

さて学習後に書き変える前にユニットテストをAI(ClaudeCode)に書いてもらいました(ブログの最後にコードを置きました)。このテストを動かしながらリファクタリングしたので捗りました。

リファクタリングした結果は以下になりました。たいして変わっていないですね。😅

const dataSchema = z.object({
  date: z.string().min(1, {message: "日付を入力して下さい"})
    .pipe(z.coerce.date({message: "正しい日付を入力して下さい"})),
  item: z.string().min(1, {message: "項目を入力して下さい"}),
  amount: z.string().min(1, {message: "金額を入力して下さい"})
    .pipe(z.coerce.number<string>({message: "正しい金額を入力して下さい"})
    .min(1, {message: "金額は1円以上で入力して下さい"})),
  payingIn: z.enum(["y", "n"], {message: "正しい入金・出金を入力して下さい"})
});

しかし、謎の.transform(Number).pipe(z.number(...))を無くせました❗

date:と同様にz.coerce.number(...)を使えるようになりました。
ここで型パラメーター<string>を指定しているのは、pipe()z.coerce.number()に渡す型がstringで、z.coerce.number()の受け取る型のデフォルトがunknownだからだそうです(AIが教えてくれました)。

まとめ

Zodを学習し、謎定義は無くなり頭の中のモヤモヤが晴れました❗ しかし、スキーマ定義はあまりスッキリしませんでした。🥺

今回は空文字列には"・・・を入力して下さい"エラーを、形式エラーには"・・・を入力して下さい"エラーを表示しようとしているからです。 考えるに、文字列(string)型は空文字列も含まれるので、どうしても特別な処理が必要になってしまうからだと思います。
スキーマ定義をスッキリさせるには、

  • 空文字列のチェックを諦める。空文字も形式エラーになるので、サーバー側でのチェックは空文字列チェックはいらないかもしれません
  • 入力が無い時は、空文字列ではなくnullやundefinedが渡ってくるようコードを変更する。nullやundefineは文字列とは別の型なのでスキーマ定義しやすいと思います

 
 

参考: テストコード

AIが生成したコードには間違いがあったので修正しました。またencodeDataでは実装されてない機能のテストはスキップしました。

import { test, describe } from 'node:test';
import assert from 'node:assert';
import encodeData, { type StringDataType, type ValidateResultType } from './encodeData.js';

describe('encodeData関数', () => {
  describe('有効なデータのシナリオ', () => {
    test('payingIn "y"で正しいデータをバリデーションできる', () => {
      const input: StringDataType = {
        date: '2023-12-01',
        item: 'Test item',
        amount: '1000',
        payingIn: 'y'
      };

      const result: ValidateResultType = encodeData(input);

      assert.strictEqual(result.errors.length, 0);
      assert.strictEqual(result.data.item, 'Test item');
      assert.strictEqual(result.data.amount, 1000);
      assert.ok(result.data.date instanceof Date);
      assert.strictEqual(result.data.date.getFullYear(), 2023);
      assert.strictEqual(result.data.date.getMonth(), 11); // December is month 11
      assert.strictEqual(result.data.date.getDate(), 1);
    });

    test('payingIn "n"で正しいデータをバリデーションできる', () => {
      const input: StringDataType = {
        date: '2024-06-15',
        item: 'Another item',
        amount: '500',
        payingIn: 'n'
      };

      const result: ValidateResultType = encodeData(input);

      assert.strictEqual(result.errors.length, 0);
      assert.strictEqual(result.data.item, 'Another item');
      assert.strictEqual(result.data.amount, 500);
      assert.ok(result.data.date instanceof Date);
    });

    test('最小有効金額(1円)を処理できる', () => {
      const input: StringDataType = {
        date: '2024-01-01',
        item: 'Minimum amount',
        amount: '1',
        payingIn: 'y'
      };

      const result: ValidateResultType = encodeData(input);

      assert.strictEqual(result.errors.length, 0);
      assert.strictEqual(result.data.amount, 1);
    });

    test('大きな金額の値を処理できる', () => {
      const input: StringDataType = {
        date: '2024-01-01',
        item: 'Large amount',
        amount: '999999',
        payingIn: 'y'
      };

      const result: ValidateResultType = encodeData(input);

      assert.strictEqual(result.errors.length, 0);
      assert.strictEqual(result.data.amount, 999999);
    });

    test('様々な日付フォーマットを処理できる', () => {
      const dateFormats = [
        '2024-01-01',
        '2024/01/01',
        '01-01-2024',
        'Jan 1, 2024'
      ];

      dateFormats.forEach(dateStr => {
        const input: StringDataType = {
          date: dateStr,
          item: 'Date test',
          amount: '100',
          payingIn: 'y'
        };

        const result: ValidateResultType = encodeData(input);
        assert.strictEqual(result.errors.length, 0, `Failed for date format: ${dateStr}`);
        assert.ok(result.data.date instanceof Date, `Date not parsed correctly for: ${dateStr}`);
      });
    });
  });

  describe('無効なデータのシナリオ', () => {
    test('空の日付に対してエラーを返す', () => {
      const input: StringDataType = {
        date: '',
        item: 'Test item',
        amount: '1000',
        payingIn: 'y'
      };

      const result: ValidateResultType = encodeData(input);

      assert.strictEqual(result.errors.length, 1);
      assert.strictEqual(result.errors[0], '日付を入力して下さい');
      assert.ok(result.data.date instanceof Date);
      assert.strictEqual(result.data.item, '');
      assert.strictEqual(result.data.amount, 0);
    });

    test('無効な日付フォーマットに対してエラーを返す', () => {
      const input: StringDataType = {
        date: 'invalid-date',
        item: 'Test item',
        amount: '1000',
        payingIn: 'y'
      };

      const result: ValidateResultType = encodeData(input);

      assert.strictEqual(result.errors.length, 1);
      assert.strictEqual(result.errors[0], '正しい日付を入力して下さい');
    });

    test('空の項目に対してエラーを返す', () => {
      const input: StringDataType = {
        date: '2024-01-01',
        item: '',
        amount: '1000',
        payingIn: 'y'
      };

      const result: ValidateResultType = encodeData(input);

      assert.strictEqual(result.errors.length, 1);
      assert.strictEqual(result.errors[0], '項目を入力して下さい');
    });

    test('空の金額に対してエラーを返す', () => {
      const input: StringDataType = {
        date: '2024-01-01',
        item: 'Test item',
        amount: '',
        payingIn: 'y'
      };

      const result: ValidateResultType = encodeData(input);

      assert.strictEqual(result.errors.length, 1);
      assert.strictEqual(result.errors[0], '金額を入力して下さい');
    });

    test('数値以外の金額に対してエラーを返す', () => {
      const input: StringDataType = {
        date: '2024-01-01',
        item: 'Test item',
        amount: 'not-a-number',
        payingIn: 'y'
      };

      const result: ValidateResultType = encodeData(input);

      assert.strictEqual(result.errors.length, 1);
      assert.strictEqual(result.errors[0], '正しい金額を入力して下さい');
    });

    test('0円の金額に対してエラーを返す', () => {
      const input: StringDataType = {
        date: '2024-01-01',
        item: 'Test item',
        amount: '0',
        payingIn: 'y'
      };

      const result: ValidateResultType = encodeData(input);

      assert.strictEqual(result.errors.length, 1);
      assert.strictEqual(result.errors[0], '金額は1円以上で入力して下さい');
    });

    test('負の金額に対してエラーを返す', () => {
      const input: StringDataType = {
        date: '2024-01-01',
        item: 'Test item',
        amount: '-100',
        payingIn: 'y'
      };

      const result: ValidateResultType = encodeData(input);

      assert.strictEqual(result.errors.length, 1);
      assert.strictEqual(result.errors[0], '金額は1円以上で入力して下さい');
    });

    test('無効なpayingIn値に対してエラーを返す', () => {
      const input: StringDataType = {
        date: '2024-01-01',
        item: 'Test item',
        amount: '1000',
        payingIn: 'invalid' as any
      };

      const result: ValidateResultType = encodeData(input);

      assert.strictEqual(result.errors.length, 1);
      assert.strictEqual(result.errors[0], '正しい入金・出金を入力して下さい');
    });

    test('複数の無効なフィールドに対して複数のエラーを返す', () => {
      const input: StringDataType = {
        date: '',
        item: '',
        amount: '',
        payingIn: 'invalid' as any
      };

      const result: ValidateResultType = encodeData(input);
      assert.strictEqual(result.errors.length, 4);
      assert.ok(result.errors.includes('日付を入力して下さい'));
      assert.ok(result.errors.includes('項目を入力して下さい'));
      assert.ok(result.errors.includes('金額を入力して下さい'));
      assert.ok(result.errors.includes('正しい入金・出金を入力して下さい'));
    });

    test('無効な日付とpayingInに対して複数のエラーを返す', () => {
      const input: StringDataType = {
        date: 'invalid-date',
        item: 'Test item',
        amount: '1000',
        payingIn: 'maybe' as any
      };

      const result: ValidateResultType = encodeData(input);

      assert.strictEqual(result.errors.length, 2);
      assert.ok(result.errors.includes('正しい日付を入力して下さい'));
      assert.ok(result.errors.includes('正しい入金・出金を入力して下さい'));
    });
  });

  describe('エッジケース', () => {
    test('空白文字のみのフィールドを空として処理する', () => {
      const _input: StringDataType = {
        date: '   ',
        item: '  ',
        amount: ' ',
        payingIn: 'y'
      };
      test.skip('スキップ: 将来の実装しよう');
    });

    test('小数点の金額を適切に処理する', () => {
      const input: StringDataType = {
        date: '2024-01-01',
        item: 'Decimal amount',
        amount: '100.5',
        payingIn: 'y'
      };

      const result: ValidateResultType = encodeData(input);

      assert.strictEqual(result.errors.length, 0);
      assert.strictEqual(result.data.amount, 100.5);
    });

    test('有効データの前後の空白文字を処理する', () => {
      const input: StringDataType = {
        date: ' 2024-01-01 ',
        item: ' Test item ',
        amount: ' 1000 ',
        payingIn: 'y'
      };

      const result: ValidateResultType = encodeData(input);

      assert.strictEqual(result.errors.length, 0);
      assert.strictEqual(result.data.item, ' Test item ');
      assert.strictEqual(result.data.amount, 1000);
    });
  });

  describe('戻り値の構造', () => {
    test('常にValidateResultType構造を返す', () => {
      const input: StringDataType = {
        date: '2024-01-01',
        item: 'Test',
        amount: '100',
        payingIn: 'y'
      };

      const result: ValidateResultType = encodeData(input);

      assert.ok(typeof result === 'object');
      assert.ok('data' in result);
      assert.ok('errors' in result);
      assert.ok(Array.isArray(result.errors));
      assert.ok(typeof result.data === 'object');
      assert.ok('date' in result.data);
      assert.ok('item' in result.data);
      assert.ok('amount' in result.data);
    });

    test('バリデーション失敗時にデフォルトのデータ構造を返す', () => {
      const input: StringDataType = {
        date: '',
        item: '',
        amount: '',
        payingIn: 'invalid' as any
      };

      const result: ValidateResultType = encodeData(input);

      assert.ok(result.data.date instanceof Date);
      assert.strictEqual(result.data.item, '');
      assert.strictEqual(result.data.amount, 0);
      assert.ok(result.errors.length > 0);
    });
  });
});

テスト実行結果

> tsx --test --test-reporter=spec

▶ encodeData関数
  ▶ 有効なデータのシナリオ
    ✔ payingIn "y"で正しいデータをバリデーションできる (1.608791ms)
    ✔ payingIn "n"で正しいデータをバリデーションできる (0.163833ms)
    ✔ 最小有効金額(1円)を処理できる (0.10775ms)
    ✔ 大きな金額の値を処理できる (0.303042ms)
    ✔ 様々な日付フォーマットを処理できる (0.146792ms)
  ✔ 有効なデータのシナリオ (2.732417ms)
  ▶ 無効なデータのシナリオ
    ✔ 空の日付に対してエラーを返す (0.392667ms)
    ✔ 無効な日付フォーマットに対してエラーを返す (0.084708ms)
    ✔ 空の項目に対してエラーを返す (0.254792ms)
    ✔ 空の金額に対してエラーを返す (0.114375ms)
    ✔ 数値以外の金額に対してエラーを返す (0.173083ms)0円の金額に対してエラーを返す (0.065292ms)
    ✔ 負の金額に対してエラーを返す (0.052541ms)
    ✔ 無効なpayingIn値に対してエラーを返す (0.062666ms)
    ✔ 複数の無効なフィールドに対して複数のエラーを返す (0.112708ms)
    ✔ 無効な日付とpayingInに対して複数のエラーを返す (0.107166ms)
  ✔ 無効なデータのシナリオ (1.596083ms)
  ▶ エッジケース
    ▶ 空白文字のみのフィールドを空として処理する
      ﹣ スキップ: 将来の実装しよう (0.060083ms) # SKIP
    ✔ 空白文字のみのフィールドを空として処理する (0.13825ms)
    ✔ 小数点の金額を適切に処理する (0.035209ms)
    ✔ 有効データの前後の空白文字を処理する (0.073084ms)
  ✔ エッジケース (0.290334ms)
  ▶ 戻り値の構造
    ✔ 常にValidateResultType構造を返す (0.051333ms)
    ✔ バリデーション失敗時にデフォルトのデータ構造を返す (0.063542ms)
  ✔ 戻り値の構造 (0.142041ms)
✔ encodeData関数 (4.996292ms)
ℹ tests 21
ℹ suites 5
ℹ pass 20
ℹ fail 0
ℹ cancelled 0
ℹ skipped 1
ℹ todo 0
ℹ duration_ms 217.586292

- about -

EY-Office代表取締役
・プログラマー
吉田裕美の
開発者向けブログ