TypeScriptのバリデーションを強化する「Zod」を使ってみた【備忘録】

:::note info
この記事では、TypeScriptのスキーマ宣言とバリデーションのためのライブラリである**Zod**で最近躓いたりしたので、改めて実際にコードを動かしながら整理してみました。
:::

## 軽く自己紹介
初めまして、ブルと申します。
元々アプリをメインに書いていたのですが、最近フロントエンドを書き始めてアドベントカレンダーの季節でもあったので改めてここでZodを整理してみました。

## はじめに

Zodは、TypeScript向けのスキーマ宣言とバリデーションライブラリで、スキーマ（単純な文字列から複雑なネストオブジェクトまでのデータ構造）を簡潔に定義し、型安全を保ちながらバリデーションを行えます。

Zodの主な目的は、型安全なバリデーションを提供し、開発者が定義したスキーマに基づいて正確なデータ検証を可能にすることです。

## スキーマの定義と基本的な使い方

Zodを使用して、データのスキーマを定義し、それに基づいてバリデーションを行います。

```typescript
import { z } from 'zod';

// ユーザースキーマの定義
const userSchema = z.object({
  name: z.string(),
  age: z.number(),
  email: z.string().email(),
});

// バリデーションするデータ
const userData = {
  name: '太郎',
  age: 30,
  email: 'taro@example.com',
};

// バリデーションの実行
try {
  const validatedData = userSchema.parse(userData);
  console.log('バリデーション成功:', validatedData);
} catch (e) {
  console.error('バリデーションエラー:', e.errors);
}
```

**出力結果:**

```
バリデーション成功: { name: '太郎', age: 30, email: 'taro@example.com' }
```

`userSchema`でユーザーオブジェクトのスキーマを定義し、`parse`メソッドでデータをバリデーションしています。
成功すると、バリデートされたデータが取得できます。

## バリデーションエラーの処理

不正なデータをバリデーションすると、エラー情報が取得できます。

```typescript
const invalidUserData = {
  name: '花子',
  age: '二十歳', // 不正な型（stringではなくnumberが期待される）
  email: 'invalid-email', // 不正なメールアドレス形式
};

try {
  const validatedData = userSchema.parse(invalidUserData);
  console.log('バリデーション成功:', validatedData);
} catch (e) {
  console.error('バリデーションエラー:', e.errors);
}
```

**出力結果:**

```
バリデーションエラー: [
  {
    code: 'invalid_type',
    expected: 'number',
    received: 'string',
    path: ['age'],
    message: 'Expected number, received string',
  },
  {
    validation: 'email',
    code: 'invalid_string',
    message: 'Invalid email',
    path: ['email'],
  },
]
```

## 型の推論と`z.infer`の活用

ZodのスキーマからTypeScriptの型を推論するには、`z.infer`を使用します。

```typescript
type User = z.infer<typeof userSchema>;

const newUser: User = {
  name: '次郎',
  age: 25,
  email: 'jiro@example.com',
};
```

`z.infer<typeof userSchema>`を使うことで、`userSchema`に基づいた`User`型を生成できます。

### `z.infer`のメリット

- 型定義とバリデーションロジックが同じ1つのソースから作られるので、不整合を防げます
- 型の重複定義を避けられたりします

## スキーマの拡張と`extend`の活用

既存のスキーマに新しいフィールドを追加する場合、`extend`メソッドが便利です。

```typescript
const addressSchema = z.object({
  street: z.string(),
  city: z.string(),
  zipCode: z.string().length(7),
});

// ユーザースキーマを拡張
const extendedUserSchema = userSchema.extend({
  address: addressSchema,
});

// バリデーションするデータ
const extendedUserData = {
  name: '太郎',
  age: 30,
  email: 'taro@example.com',
  address: {
    street: '中央通り1-1-1',
    city: '東京都',
    zipCode: '1234567',
  },
};

try {
  const validatedData = extendedUserSchema.parse(extendedUserData);
  console.log('バリデーション成功:', validatedData);
} catch (e) {
  console.error('バリデーションエラー:', e.errors);
}
```

**出力結果:**

```
バリデーション成功: {
  name: '太郎',
  age: 30,
  email: 'taro@example.com',
  address: { street: '中央通り1-1-1', city: '東京都', zipCode: '1234567' }
}
```

### `extend`のメリット

- 既存のスキーマをそのまま活用したいケースで便利です
- 変更があった場合でも、一箇所の修正で済みます

## オプショナルなフィールドとデフォルト値

Zodでは、フィールドをオプショナルにしたり、デフォルト値を設定することができます。

```typescript
const optionalUserSchema = z.object({
  name: z.string(),
  nickname: z.string().optional(),
  age: z.number().default(20),
});

// データ例
const userData1 = { name: '太郎', nickname: 'タロちゃん' };
const userData2 = { name: '花子' };

try {
  console.log('ユーザー1:', optionalUserSchema.parse(userData1));
  console.log('ユーザー2:', optionalUserSchema.parse(userData2));
} catch (e) {
  console.error('バリデーションエラー:', e.errors);
}
```

**出力結果:**

```
ユーザー1: { name: '太郎', nickname: 'タロちゃん', age: 20 }
ユーザー2: { name: '花子', age: 20 }
```

### 詳細

- **`optional()`**: フィールドを省略可能にします。
- **`default()`**: フィールドが存在しない場合、デフォルト値を設定します。

## カスタムバリデーションと`refine`

独自のバリデーションロジックを追加したい場合、`refine`メソッドを使用します。

```typescript
const passwordSchema = z.string().min(8).refine((val) => /[A-Z]/.test(val), {
  message: 'パスワードには少なくとも一つの大文字が必要です。',
});

const passwords = ['password', 'Password123'];

passwords.forEach((pwd) => {
  try {
    passwordSchema.parse(pwd);
    console.log(`パスワード "${pwd}" は有効です。`);
  } catch (e) {
    console.error(`パスワード "${pwd}" は無効です。`, e.errors);
  }
});
```

**出力結果:**

```
パスワード "password" は無効です。 [
  {
    code: 'custom',
    message: 'パスワードには少なくとも一つの大文字が必要です。',
    path: [],
  },
]
パスワード "Password123" は有効です。
```

### `refine`の活用方法

- 正規表現や複数の条件を組み合わせたバリデーションを扱いたい時
- 柔軟にバリデーションの文言を変えたい時

## React Hook Formとの統合

フロントエンド開発でフォームバリデーションを行う際、React Hook FormとZodを組み合わせると効果的です。

### 必要なパッケージのインストール

```bash
npm install react-hook-form @hookform/resolvers
```

### フォームスキーマの定義

```typescript
import { z } from 'zod';

const formSchema = z.object({
  username: z.string().min(1, { message: 'ユーザー名は必須です。' }),
  email: z.string().email({ message: '有効なメールアドレスを入力してください。' }),
  password: z.string().min(8, { message: 'パスワードは8文字以上である必要があります。' }),
});

type FormValues = z.infer<typeof formSchema>;
```

### フォームコンポーネントの実装

```tsx
import React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';

const App: React.FC = () => {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<FormValues>({
    resolver: zodResolver(formSchema),
  });

  const onSubmit = (data: FormValues) => {
    console.log('フォームデータ:', data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label>ユーザー名:</label>
        <input {...register('username')} />
        {errors.username && <p>{errors.username.message}</p>}
      </div>
      <div>
        <label>メールアドレス:</label>
        <input {...register('email')} />
        {errors.email && <p>{errors.email.message}</p>}
      </div>
      <div>
        <label>パスワード:</label>
        <input type="password" {...register('password')} />
        {errors.password && <p>{errors.password.message}</p>}
      </div>
      <button type="submit">送信</button>
    </form>
  );
};

export default App;
```

### フォームの動作確認

- フォームを送信すると、Zodによるバリデーションが実行されます。
- `errors`オブジェクトから取得したエラーメッセージを表示します。

## Zodのその他色々

### `z.string()`のメソッド

#### `emoji()`

文字列が全て絵文字で構成されているかを検証します。

```typescript
const emojiSchema = z.string().emoji();

const testStrings = ['😊👍', 'Hello', 'こんにちは😊'];

testStrings.forEach((str) => {
  try {
    emojiSchema.parse(str);
    console.log(`"${str}" は全て絵文字です。`);
  } catch (e) {
    console.error(`"${str}" は絵文字のみではありません。`);
  }
});
```

**出力結果:**

```
"😊👍" は全て絵文字です。
"Hello" は絵文字のみではありません。
"こんにちは😊" は絵文字のみではありません。
```

#### `ip()`

IPアドレスのバリデーションが可能です。

```typescript
const ipSchema = z.string().ip();

const ips = ['192.168.1.1', '2001:0db8:85a3:0000:0000:8a2e:0370:7334', 'invalid-ip'];

ips.forEach((ip) => {
  try {
    ipSchema.parse(ip);
    console.log(`"${ip}" は有効なIPアドレスです。`);
  } catch (e) {
    console.error(`"${ip}" は無効なIPアドレスです。`);
  }
});
```

**出力結果:**

```
"192.168.1.1" は有効なIPアドレスです。
"2001:0db8:85a3:0000:0000:8a2e:0370:7334" は有効なIPアドレスです。
"invalid-ip" は無効なIPアドレスです。
```

### `z.bigint()`の拡張

以下は`z.bigint()`に数値型と同様の比較メソッドの例です。
```typescript
const bigIntSchema = z.bigint().gt(BigInt(100));

const bigInts = [BigInt(200), BigInt(50)];

bigInts.forEach((num) => {
  try {
    bigIntSchema.parse(num);
    console.log(`${num} は100より大きいです。`);
  } catch (e) {
    console.error(`${num} は100以下です。`);
  }
});
```

**出力結果:**

```
200n は100より大きいです。
50n は100以下です。
```

## まとめ

TypeScriptを書く中で時々遭遇する**Zod**について、改めて整理してみました。  
今回はメリットに焦点を当てていますが、今後はアンチパターンについても考察してみたいと思います。

Zodを活用すれば、**型定義と実行時バリデーションを一元化**することで、開発効率を向上させる可能性がありますし、`z.infer`や`extend`を活用することで、**型定義とスキーマを常に同期**させられるメリットがあります。

また、公式にもある通り、React Hook Formと組み合わせれば、**フロントエンドのフォームバリデーションがより効率的に**行えるとのことなので、ぜひ活用してみてください！

## 参考資料

- [Zod公式ドキュメント](https://zod.dev/)
- [React Hook Form公式サイト](https://react-hook-form.com/)
- [React Hook Form - Zodとの統合](https://react-hook-form.com/get-started#SchemaValidation)