1
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?

ゼロから構築!TypeScript型安全性を最大化する状態管理設計

1
Posted at

多くのTypeScriptプロジェクトで状態管理を導入する際、「ランタイムのデータ検証が甘くてバグを生む」「状態の型定義が複雑になりすぎる」といった課題に直面したことはありませんか?特に大規模なアプリケーションでは、データの整合性や型安全性の担保が開発効率と保守性に直結します。

この記事では、TypeScriptの高度な型機能を最大限に活用し、Zustandによる軽量かつスケーラブルな状態管理と、Zodによる堅牢なランタイムバリデーションを組み合わせた、TypeScript型設計を最大化する状態管理設計パターンを解説します。これにより、開発時のバグを大幅に削減し、長期的な保守性の高いアプリケーション構築が可能になります。

前提知識と環境

このセクションでは、記事で扱う技術スタックのバージョンと基本的な概念について説明します。

本記事は以下の環境を想定しています。

  • Node.js: v18以上
  • TypeScript: 5.5(記事執筆時点の最新安定版)
  • Zustand: 4.5.2(記事執筆時点の最新安定版)
  • Zod: 3.23.8(記事執筆時点の最新安定版)
  • React: 18以上(ZustandはReact非依存ですが、コード例ではReactコンポーネントでの利用を想定しています)

各ライブラリの基本的な使い方を把握していると、より理解が深まります。

Zustandによる型安全な状態管理の基本

Zustandは、軽量で高速な状態管理ライブラリであり、React Hooksに基づいたシンプルなAPIを提供します。ここでは、Zustandストアの基本的な作成方法と、TypeScriptでの型安全な状態管理を実現するための型定義について解説します。

ストアの作成とTypeScript型定義

Zustandでストアを作成する際は、まずストアの状態とアクションのインターフェースを定義することがベストプラクティスです。これにより、コンパイル時に型チェックが働き、安全な状態操作が可能になります。

// store/bearStore.ts
import { create } from 'zustand';

// ストアの状態とアクションの型定義
interface BearState {
  bears: number;
  food: string;
  feed: (food: string) => void;
  increasePopulation: () => void;
  removeAllBears: () => void;
}

// ストアの作成
// create<BearState>() とすることで、ストアが BearState 型に準拠することを強制します。
export const useBearStore = create<BearState>()((set) => ({
  bears: 0,
  food: 'honey',
  // foodを更新するアクション
  feed: (food) => set(() => ({ food })), 
  // bearsを増やすアクション
  increasePopulation: () => set((state) => ({ bears: state.bears + 1 })), 
  // bearsをリセットするアクション
  removeAllBears: () => set({ bears: 0 }), 
}));

このコードでは、BearStateインターフェースでストアが持つべき状態(bears, food)と、それらを操作するアクション(feed, increasePopulation, removeAllBears)を厳密に定義しています。create<BearState>() とすることで、Zustandストアがこの型定義に準拠していることをTypeScriptが保証します。

コンポーネントでのストアの使用と再レンダリングの最適化

コンポーネントでZustandストアを使用する際は、必要な状態のみをセレクタで選択することで、不要な再レンダリングを防ぎ、パフォーマンスを最適化できます。

// components/BearCounter.tsx
import React from 'react';
import { useBearStore } from '../store/bearStore';
import { useShallow } from 'zustand/react/shallow'; // Zustand v4以降で推奨

function BearCounter() {
  // 必要な状態のみを個別に選択する
  const bears = useBearStore((state) => state.bears);
  const increasePopulation = useBearStore((state) => state.increasePopulation);
  const food = useBearStore((state) => state.food); // foodが変更されてもbearsを使用しているコンポーネントは再レンダリングされない

  // 複数の状態をまとめて選択し、シャロー比較で再レンダリングを最適化する場合
  // const { bears, food } = useBearStore(
  //   useShallow((state) => ({ bears: state.bears, food: state.food }))
  // );

  return (
    <div>
      <h1>{bears} bears</h1>
      <p>Current food: {food}</p>
      <button onClick={increasePopulation}>Add bear</button>
      <button onClick={() => useBearStore.getState().removeAllBears()}>Remove All Bears</button>
      <button onClick={() => useBearStore.getState().feed('berry')}>Change Food to Berry</button>
    </div>
  );
}

export default BearCounter;

useShallowを使用することで、複数の状態をまとめて選択した場合でも、それらの値がシャロー比較で等しい場合にのみコンポーネントが再レンダリングされるようになります。これは、Zustandにおける再レンダリング最適化の重要なテクニックです。

Zodによるランタイムバリデーションと型推論

Zodは、TypeScriptファーストのスキーマ宣言およびバリデーションライブラリです。一度スキーマを定義するだけで、ランタイムバリデーションとTypeScriptの静的型推論の両方を提供し、型の重複を排除します。このセクションでは、Zodの基本的な使い方と、TypeScriptの型安全設計を強化する方法を解説します。

スキーマ定義とTypeScript型推論

Zodの最大の利点は、スキーマからTypeScriptの型を自動的に推論できる点です。これにより、型定義とバリデーションロジックの一貫性が保証されます。

// schemas/userSchema.ts
import { z } from 'zod';

// ユーザー情報のZodスキーマを定義
const userSchema = z.object({
  id: z.string().uuid('無効なUUID形式です'),
  name: z.string().min(2, '名前は2文字以上である必要があります'),
  email: z.string().email('無効なメールアドレスです'),
  age: z.number().int('年齢は整数である必要があります').positive('年齢は正の整数である必要があります').optional(),
  roles: z.array(z.enum(['admin', 'editor', 'viewer'])).default(['viewer']),
  // 日付文字列をDateオブジェクトに変換する
  createdAt: z.string().datetime().transform((str) => new Date(str)).optional(), 
});

// スキーマからTypeScriptの型を自動推論
export type User = z.infer<typeof userSchema>;

// データのパースとバリデーションの例
try {
  const validUser: User = userSchema.parse({
    id: 'a1b2c3d4-e5f6-7890-1234-567890abcdef',
    name: 'John Doe',
    email: 'john.doe@example.com',
    createdAt: '2023-01-01T10:00:00.000Z',
  });
  console.log('Valid User:', validUser);
  // 出力例: { id: '...', name: 'John Doe', email: '...', roles: ['viewer'], createdAt: Dateオブジェクト }

  // 不正なデータ例: ここでZodErrorがスローされる
  // userSchema.parse({
  //   id: 'invalid-uuid',
  //   name: 'J',
  //   email: 'invalid-email',
  // });
} catch (error) {
  if (error instanceof z.ZodError) {
    console.error('Validation Errors:', error.errors);
    /*
    出力例:
    [
      { code: 'invalid_string', message: '無効なUUID形式です', ... },
      { code: 'too_small', message: '名前は2文字以上である必要があります', ... },
      { code: 'invalid_string', message: '無効なメールアドレスです', ... }
    ]
    */
  }
}

z.infer<typeof userSchema> を使うことで、userSchemaからUser型が自動的に生成されます。これにより、バリデーションロジックとTypeScriptの型定義を別々に管理する必要がなくなり、常に同期された状態を保てます。

カスタムバリデーションとデータ変換

Zodは、refine(), superRefine(), transform(), preprocess() といったメソッドを提供し、より複雑なバリデーションやデータ変換を可能にします。

// schemas/authSchema.ts
import { z } from 'zod';

// パスワードスキーマ: 8文字以上、大文字、数字を含む
export const passwordSchema = z.string()
  .min(8, 'パスワードは8文字以上である必要があります')
  .refine(val => /[A-Z]/.test(val), { message: 'パスワードには大文字を含める必要があります' })
  .refine(val => /[0-9]/.test(val), { message: 'パスワードには数字を含める必要があります' });

// 日付文字列をDateオブジェクトに変換するスキーマ
export const dateStringSchema = z.string().transform((str) => new Date(str));

// 入力値の前処理 (トリミング) を行うスキーマ
export const trimmedStringSchema = z.preprocess(
  (val) => (typeof val === 'string' ? val.trim() : val), // 文字列であればトリミング
  z.string().min(1, '空の文字列は許可されません')
);

// 使用例
try {
  passwordSchema.parse('Password123'); // OK
  // passwordSchema.parse('pass123'); // エラー: 大文字がない
  // passwordSchema.parse('PASSWORD'); // エラー: 数字がない

  const date = dateStringSchema.parse('2024-06-01T12:00:00Z');
  console.log('Parsed Date:', date); // Dateオブジェクトが出力される

  const trimmed = trimmedStringSchema.parse('  hello world  ');
  console.log('Trimmed String:', trimmed); // "hello world" が出力される
} catch (error) {
  if (error instanceof z.ZodError) {
    console.error('Validation Error:', error.errors);
  }
}

refine()は条件付きのバリデーションを追加し、transform()はバリデーション後にデータを変換します。preprocess()はバリデーション前にデータの前処理を行うため、ユーザー入力の整形などに非常に便利です。これらを活用することで、より柔軟で堅牢なデータ処理が可能になります。

ZustandとZodを組み合わせた高度な状態管理設計

ここからは、ZustandとZodを統合し、エンドツーエンドのTypeScript型安全性を確保する状態管理設計パターンを具体的に見ていきます。外部から取得したデータをZodで検証し、その結果をZustandストアに型安全に格納するフローを構築します。

APIレスポンスのZodバリデーションとZustandへの格納

外部APIからのレスポンスは信頼できないデータソースであるため、Zodで厳密にバリデーションし、安全が確認されたデータのみをZustandストアに格納することが重要です。

// store/userStore.ts
import { create } from 'zustand';
import { z } from 'zod';

// APIレスポンスのユーザー情報スキーマ
const apiUserSchema = z.object({
  id: z.string().uuid(),
  name: z.string(),
  email: z.string().email(),
  // APIによっては age が string でくる可能性も考慮し、preprocess で数値に変換
  age: z.preprocess((val) => Number(val), z.number().int().positive()).optional(),
});

// Zodスキーマからストアに格納するユーザー情報の型を推論
export type UserData = z.infer<typeof apiUserSchema>;

interface UserState {
  currentUser: UserData | null;
  isLoading: boolean;
  error: string | null;
  fetchUser: (userId: string) => Promise<void>;
  updateUser: (userData: Partial<UserData>) => Promise<void>;
}

export const useUserStore = create<UserState>()((set, get) => ({
  currentUser: null,
  isLoading: false,
  error: null,

  fetchUser: async (userId: string) => {
    set({ isLoading: true, error: null });
    try {
      // 実際はAPIコール
      const response: unknown = await new Promise((resolve) =>
        setTimeout(() => {
          if (userId === 'user-123') {
            resolve({
              id: 'a1b2c3d4-e5f6-7890-1234-567890abcdef',
              name: 'Jane Doe',
              email: 'jane.doe@example.com',
              age: '30', // APIから文字列で年齢が返ってくることを想定
            });
          } else {
            resolve({ error: 'User not found' });
          }
        }, 500)
      );

      // ZodでAPIレスポンスをバリデーション
      const parsedUser = apiUserSchema.parse(response);
      set({ currentUser: parsedUser, isLoading: false });
    } catch (err) {
      if (err instanceof z.ZodError) {
        console.error('API Response Validation Error:', err.errors);
        set({ error: 'データの形式が不正です。', isLoading: false });
      } else if (err instanceof Error) {
        set({ error: err.message, isLoading: false });
      } else {
        set({ error: '不明なエラーが発生しました。', isLoading: false });
      }
    }
  },

  updateUser: async (userData: Partial<UserData>) => {
    set({ isLoading: true, error: null });
    try {
      // 更新データもZodで部分的にバリデーションする(例: name, emailのみ更新する場合)
      const updateSchema = apiUserSchema.partial(); // Partial<UserData> に対応
      const validatedUpdate = updateSchema.parse(userData);

      // 実際はAPIコールで更新
      await new Promise((resolve) => setTimeout(resolve, 300));

      set((state) => ({
        currentUser: state.currentUser ? { ...state.currentUser, ...validatedUpdate } : null,
        isLoading: false,
      }));
    } catch (err) {
      if (err instanceof z.ZodError) {
        console.error('Update Data Validation Error:', err.errors);
        set({ error: '更新データの形式が不正です。', isLoading: false });
      } else if (err instanceof Error) {
        set({ error: err.message, isLoading: false });
      } else {
        set({ error: '不明なエラーが発生しました。', isLoading: false });
      }
    }
  },
}));

// コンポーネントでの使用例 (React)
// function UserProfile() {
//   const { currentUser, isLoading, error, fetchUser, updateUser } = useUserStore();
//
//   React.useEffect(() => {
//     fetchUser('user-123');
//   }, [fetchUser]);
//
//   if (isLoading) return <div>Loading user data...</div>;
//   if (error) return <div>Error: {error}</div>;
//   if (!currentUser) return <div>No user data.</div>;
//
//   return (
//     <div>
//       <h2>User Profile</h2>
//       <p>ID: {currentUser.id}</p>
//       <p>Name: {currentUser.name}</p>
//       <p>Email: {currentUser.email}</p>
//       <p>Age: {currentUser.age ?? 'N/A'}</p>
//       <button onClick={() => updateUser({ name: 'Jane A. Doe' })}>Update Name</button>
//     </div>
//   );
// }

この設計では、fetchUserアクション内でAPIレスポンスをapiUserSchema.parse()でバリデーションしています。バリデーションに成功すると、parsedUserUserData型として扱われるため、Zustandストアへの格納も型安全に行われます。これにより、ランタイムでの予期せぬデータ形式によるエラーを防ぎ、コンパイル時にも安全性が保証されます。

フォーム入力とZod、Zustandの連携

ユーザーからのフォーム入力も信頼できないデータであるため、Zodでバリデーションし、Zustandストアに安全に反映させるパターンを考えます。

// schemas/formSchema.ts
import { z } from 'zod';

export const userFormSchema = z.object({
  name: z.string().min(2, '名前は2文字以上で入力してください'),
  email: z.string().email('有効なメールアドレスを入力してください'),
  // ageは文字列で入力される可能性があるので、preprocessで数値に変換し、バリデーション
  age: z.preprocess(
    (val) => (val === '' ? undefined : Number(val)),
    z.number().int('年齢は整数で入力してください').positive('年齢は正の数で入力してください').optional()
  ),
});

export type UserFormData = z.infer<typeof userFormSchema>;

// components/UserForm.tsx
import React, { useState } from 'react';
import { useUserStore, UserData } from '../store/userStore';
import { userFormSchema, UserFormData } from '../schemas/formSchema';
import { z } from 'zod';

function UserForm() {
  const { currentUser, updateUser } = useUserStore();
  const [formData, setFormData] = useState<UserFormData>({
    name: currentUser?.name || '',
    email: currentUser?.email || '',
    age: currentUser?.age,
  });
  const [errors, setErrors] = useState<z.ZodIssue[]>([]);

  // 初期値としてcurrentUserを反映
  React.useEffect(() => {
    if (currentUser) {
      setFormData({
        name: currentUser.name,
        email: currentUser.email,
        age: currentUser.age,
      });
    }
  }, [currentUser]);

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setFormData((prev) => ({ ...prev, [name]: value }));
  };

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setErrors([]); // エラーをリセット

    try {
      // Zodでフォームデータをバリデーション
      const validatedData = userFormSchema.parse(formData);
      console.log('Validated Form Data:', validatedData);

      // バリデーションが通れば、Zustandストアのアクションを呼び出す
      // UserData型とUserFormData型はz.inferで生成されているため互換性がある
      await updateUser(validatedData); 
      alert('ユーザー情報を更新しました!');
    } catch (error) {
      if (error instanceof z.ZodError) {
        console.error('Form Validation Errors:', error.errors);
        setErrors(error.errors); // バリデーションエラーをUIに表示
      } else {
        console.error('An unexpected error occurred:', error);
      }
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="name">Name:</label>
        <input
          type="text"
          id="name"
          name="name"
          value={formData.name}
          onChange={handleChange}
        />
        {errors.find((err) => err.path[0] === 'name') && (
          <p style={{ color: 'red' }}>
            {errors.find((err) => err.path[0] === 'name')?.message}
          </p>
        )}
      </div>
      <div>
        <label htmlFor="email">Email:</label>
        <input
          type="email"
          id="email"
          name="email"
          value={formData.email}
          onChange={handleChange}
        />
        {errors.find((err) => err.path[0] === 'email') && (
          <p style={{ color: 'red' }}>
            {errors.find((err) => err.path[0] === 'email')?.message}
          </p>
        )}
      </div>
      <div>
        <label htmlFor="age">Age:</label>
        <input
          type="number"
          id="age"
          name="age"
          value={formData.age === undefined ? '' : formData.age}
          onChange={handleChange}
        />
        {errors.find((err) => err.path[0] === 'age') && (
          <p style={{ color: 'red' }}>
            {errors.find((err) => err.path[0] === 'age')?.message}
          </p>
        )}
      </div>
      <button type="submit">Update Profile</button>
    </form>
  );
}

export default UserForm;

この例では、フォームの送信時にuserFormSchema.parse(formData)で入力データをバリデーションしています。これにより、無効なデータがZustandストアに格納されることを防ぎ、また、z.ZodErrorをキャッチしてユーザーにフィードバックを提供できます。UserFormData型とUserData型がZodスキーマから推論されているため、updateUserアクションに渡す際も型安全性が維持されます。

よくあるエラー・ハマりどころと回避策

このセクションでは、ZustandとZodを使用する際によく遭遇する問題点と、それらを回避するための具体的な方法を解説します。

Zustandでの不要な再レンダリング

Zustandは非常に軽量ですが、誤った使い方をするとReactコンポーネントの不要な再レンダリングを引き起こす可能性があります。

  • ハマりどころ: useStore((state) => state) のようにストア全体を選択したり、セレクタ内で毎回新しいオブジェクトを返したりすると、ストア内の他の状態が変更されただけでもコンポーネントが再レンダリングされてしまいます。
  • 回避策:
    • 必要な状態のスライスのみを個別に選択する: これが最も基本的な最適化です。
      const bears = useBearStore((state) => state.bears);
      const food = useBearStore((state) => state.food);
      // bearsが変更されてもfoodを使用しているコンポーネントは再レンダリングされない
      
    • 複数の状態を選択する場合はuseShallowを使用する: 複数の値をまとめてオブジェクトとして取得したい場合、shallow比較関数を useStore の第2引数に渡すことで、選択された値がシャロー比較で等しい場合にのみ再レンダリングを防ぎます。Zustand v4以降では、zustand/react/shallow から useShallow をインポートして使用することが推奨されています。
      import { useShallow } from 'zustand/react/shallow';
      
      const { bears, food } = useBearStore(
        useShallow((state) => ({ bears: state.bears, food: state.food }))
      );
      

Zustand persistミドルウェアでの状態の再ハイドレーション問題

persistミドルウェアは状態の永続化に便利ですが、初期レンダリング時のハイドレーションに注意が必要です。

  • ハマりどころ: アプリケーションが永続化された値に依存する場合、ストアがハイドレートされるまでUIが一時的にデフォルト値で表示され、その後に永続化された値で再レンダリングされることで、UIのちらつきや不一致が発生することがあります。また、ストアに直接関数を含めている場合、JSON.stringify でシリアライズされる際にその関数が失われ、リロード後に undefined になることがあります。
  • 回避策:
    • ハイドレーション完了までUI表示を待つ: アプリケーションが永続化された値に強く依存する場合は、ストアがハイドレートされるまでUIの表示を待つローディング状態を導入します。ZustandのFAQでハイドレーションの完了を確認する方法が提供されています。usePersistedStore.persist.hasHydrated() を使用するか、onRehydrateStorage コールバックで状態を管理します。
      // store/persistedBearStore.ts
      import { create } from 'zustand';
      import { persist, createJSONStorage } from 'zustand/middleware';
      
      type PersistedBearStore = {
        bears: number;
        addABear: () => void;
        resetBears: () => void;
      };
      
      export const usePersistedBearStore = create(
        persist<PersistedBearStore>(
          (set, get) => ({
            bears: 0,
            addABear: () => set({ bears: get().bears + 1 }),
            resetBears: () => set({ bears: 0 }),
          }),
          {
            name: 'bear-storage',
            storage: createJSONStorage(() => localStorage),
            // ハイドレーション完了時に呼び出されるコールバック
            onRehydrateStorage: (state) => {
              console.log('hydration starts', state);
              return (state, error) => {
                if (error) {
                  console.error('An error happened during hydration', error);
                } else {
                  console.log('hydration finished', state);
                  // ここでハイドレーション完了フラグを立てるなどの処理が可能
                }
              };
            },
          }
        )
      );
      
      // コンポーネント内
      // function PersistedBearCounter() {
      //   const hasHydrated = usePersistedBearStore.persist.hasHydrated();
      //   const { bears, addABear, resetBears } = usePersistedBearStore();
      //
      //   if (!hasHydrated) {
      //     return <div>Loading persisted state...</div>; // ハイドレーション完了までローディング表示
      //   }
      //
      //   return (
      //     <div>
      //       <h1>Persisted Bears: {bears}</h1>
      //       <button onClick={addABear}>Add Persisted Bear</button>
      //       <button onClick={resetBears}>Reset Persisted Bears</button>
      //     </div>
      //   );
      // }
      
    • 関数を永続化しない: 関数はシリアライズできないため、ストアの状態に直接含めず、アクションとして定義するか、ストアの外部で管理するようにしてください。

Zodでのany型の安易な使用

Zodを使用する目的はTypeScriptの型安全性を高めることですが、any型を安易に使うとこのメリットが失われます。

  • ハマりどころ: Zodでバリデーションする前のデータや、外部からのデータを受け取る際に any でキャストしてしまうと、Zodが提供する静的型チェックの利点が失われ、ランタイムエラーのリスクが高まります。
  • 回避策:
    • 常にZodスキーマから型を推論する: z.infer<typeof someSchema> を使用し、any 型の使用を避けます。
    • 外部からの信頼できないデータにはunknown型を使用する: APIレスポンス、ユーザー入力、環境変数など、信頼できないデータは unknown 型として受け取り、Zodの parse() または safeParse() メソッドでバリデーションを行ってから、推論された型として安全に扱います。
      import { z } from 'zod';
      
      const mySchema = z.object({
        value: z.string(),
      });
      
      // 外部からのデータは unknown として扱う
      const externalData: unknown = JSON.parse('{ "value": "hello" }');
      
      // parse() でバリデーションと型変換を行う
      try {
        const parsedData = mySchema.parse(externalData);
        // parsedData は { value: string } 型として安全に扱える
        console.log(parsedData.value.toUpperCase());
      } catch (error) {
        if (error instanceof z.ZodError) {
          console.error('Validation failed:', error.errors);
        }
      }
      

設計上のトレードオフとベストプラクティス

ここでは、ZustandとZodを活用したTypeScript型設計において、より堅牢で保守性の高いアプリケーションを構築するための設計原則と注意点について解説します。

Zustandのベストプラクティス

  • ストアをドメインごとに整理する: すべての状態を1つの巨大なグローバルストアに入れるのではなく、認証、UI、カートなど、独立したドメインごとにストアを分割します。これにより、関心の分離が促進され、保守性が向上します。
  • セレクタを活用してパフォーマンスを最適化する: コンポーネントが必要な状態のスライスのみを購読するようにし、不要な再レンダリングを防ぐために useShallow を使用します。
  • アクションを状態と結合する: アクションをストア内に定義し、ストアの状態を更新するためのクリーンなAPIを提供します。これにより、状態とロジックが密接に結びつき、理解しやすくなります。
  • アクションをイベントとしてモデル化する: 単純なセッターではなく、「ユーザーがログインした」「アイテムがカートに追加された」など、何が起こったかを記述するイベントベースのアクションを使用することで、アプリケーションの意図が明確になります。
  • persistミドルウェアで状態を永続化する: 認証情報、ユーザー設定、カートの内容など、ページのリロード後も保持したい状態に利用します。
  • devtoolsミドルウェアでデバッグを強化する: Redux DevToolsと連携させ、状態の変化の検査、タイムトラベルデバッグを可能にします。
  • グローバル状態の過度な使用を避ける: 1つのコンポーネント内でのみ使用される状態は useState を使用し、複数のコンポーネントで共有される状態のみZustandに移動します。
  • TypeScriptで型安全性を確保する: ストア、アクション、セレクタに強力な型付けを行い、コンパイル時の安全性を確保します。

Zodのベストプラクティス

  • ZodスキーマからTypeScript型を推論する: スキーマをデータ構造の唯一の真実の源とし、z.infer を使用してTypeScript型を生成することで、ランタイムバリデーションとコンパイル時型の整合性を保ちます。
  • 信頼境界でバリデーションを行う: 外部からのデータ(APIレスポンス、ユーザー入力、環境変数など)は信頼できないため、システムのエントリーポイントでZodを使って早期にバリデーションを行います。内部で生成されたデータは型安全であるため、再バリデーションは不要です。
  • transform() を活用してデータを整形する: バリデーションだけでなく、データの正規化や変換にも transform() を使用します(例: 日付文字列をDateオブジェクトに変換、文字列のトリミング)。
  • スキーマをドメインごとに整理する: すべてのスキーマを1つのファイルにまとめるのではなく、ドメインごとにモジュール化します。
  • スキーマを明示的にテストする: エッジケースを検証し、本番環境でのクラッシュを防ぎます。
  • エラーメッセージを明確にする: refinesuperRefine でカスタムエラーメッセージを提供し、ユーザーフレンドリーなフィードバックを可能にします。
  • any 型の使用を避ける: Zodの静的型チェックの利点を最大限に活用するため、any 型の使用は厳禁です。代わりに unknownparse() を使用します。
  • スキーマの再利用と結合: merge(), extend(), pick(), omit() などのメソッドを使って、既存のスキーマを再利用し、より複雑なデータ構造を構築します。

トレードオフ

  • シンプルさとスケーラビリティ (Zustand): Zustandはシンプルさを追求しているため、Reduxのような大規模なアプリケーション向けの厳格な規約や豊富なエコシステムは持たない側面があります。しかし、スライスパターンやミドルウェアを組み合わせることで、大規模なアプリケーションにも対応できる柔軟性があります。厳格な規約がない分、開発者自身が規約を設ける必要があります。
  • ランタイムオーバーヘッドと学習コスト (Zod): Zodによるランタイムバリデーションは、追加のコード実行を伴うため、わずかながらオーバーヘッドが発生します。しかし、Zodは効率的に設計されており、パフォーマンスが重要なシナリオでは、不要なバリデーションを避ける(例: 内部データには適用しない)などの最適化が可能です。また、Zodの豊富なAPIと型推論の仕組みを理解するには、ある程度の学習コストがかかります。

まとめ

この記事では、ZustandとZodを組み合わせることで、TypeScriptの型設計を最大限に活用し、堅牢で保守性の高い状態管理を実現する方法を解説しました。

重要なポイントは以下の通りです。

  1. Zustandでストアの状態とアクションを厳密に型定義し、useShallowを活用して再レンダリングを最適化する。
  2. Zodで外部からの信頼できないデータをランタイムでバリデーションし、z.inferでTypeScript型を自動推論することで、型定義とバリデーションロジックの一貫性を保つ。
  3. Zodのtransformpreprocessrefineを使い、複雑なデータ変換やカスタムバリデーションを実現する。
  4. APIレスポンスやフォーム入力などの信頼境界でZodを適用し、検証済みのデータのみをZustandストアに格納することで、エンドツーエンドの型安全性を確保する。
  5. any型の安易な使用を避け、unknown型とZodのparse()メソッドを積極的に活用する。

これらのプラクティスを導入することで、開発時のバグを減らし、大規模プロジェクトでも安心して利用できる状態管理基盤を構築できます。

さらに深く学びたい方は、各ライブラリの公式ドキュメントを参照し、より高度なミドルウェアやカスタムスキーマの構築に挑戦してみてください。

1
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
1
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?