2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

どすこい塾Advent Calendar 2024

Day 4

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

Last updated at Posted at 2024-12-04

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

軽く自己紹介

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

はじめに

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

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

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

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

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メソッドでデータをバリデーションしています。
成功すると、バリデートされたデータが取得できます。

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

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

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を使用します。

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メソッドが便利です。

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では、フィールドをオプショナルにしたり、デフォルト値を設定することができます。

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メソッドを使用します。

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を組み合わせると効果的です。

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

npm install react-hook-form @hookform/resolvers

フォームスキーマの定義

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>;

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

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()

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

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アドレスのバリデーションが可能です。

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()に数値型と同様の比較メソッドの例です。

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.inferextendを活用することで、型定義とスキーマを常に同期させられるメリットがあります。

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

参考資料

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?