💎

Zod で堅牢的な型を手に入れる[Nex.js 13 App Router]

2023/10/11に公開

はじめに

フォームバリデーションとして使われるZodですが、型の安全性を提供する役割としてもかなり優秀なので、この記事では React アプリの特定のインスタンスでその用途を紹介します。

API リクエスト

Reactアプリでは、データの形がバックエンドで変わると、フロントエンドでデータにアクセスする際に問題が発生する可能性があります。

以下は Route Handler を用いてデータ取得する例です。

app/api/user/route.ts
const mockUser = {
  id: 1,
  name: 'John Doe',
};

export async function GET() {
  return NextResponse.json(mockUser);
}

Client Component で呼び出します。

ClientComponent.tsx
const ClientComponent = () => {
  useEffect(() => {
    fetch('/api/user')
      .then((res) => res.json())
      .then((data) => console.log(data.name.toUpperCase()));
  }, []);
  
  ...
}  

data に name プロパティが存在しない場合、または name が文字列でない場合、.toUpperCase()の呼び出す際に実行時エラーとなります。
そこで TypeScript で型をつけます。

ClientComponent.tsx
type User = {
  id: number;
  name: string;
};

const ChatComponent = ({ user, chatId }: Props) => {
  useEffect(() => {
    fetch('/api/user')
      .then((res) => res.json())
      .then((data: User) => console.log(data.name.toUpperCase()));
  }, []);

ただ、バックエンド側でnamefirstNameに変更があった場合はどうでしょうか?

app/api/user/route.ts
const mockUser = {
  id: 1,
  // name: 'John Doe',
  firstName: 'John',
};

export async function GET() {
  return NextResponse.json(mockUser);
}

バックエンド側の mockUser オブジェクトに name プロパティが存在しないにもかかわらず、クライアント側でdata.name.toUpperCase()という形で name プロパティを呼び出そうとしているため、クライアント側でエラーとなります。

ClientComponent.tsx
  useEffect(() => {
    fetch('/api/user')
      .then((res) => res.json())
      // ↓ TypeError: Cannot read properties of undefined (reading 'toUpperCase')
      .then((data: User) => console.log(data.name.toUpperCase()));
  }, []);

Optional Chaining Operator?.を活用することで、バックエンドやサードパーティ API からのデータ受け取り時のアプリケーションのクラッシュを予防することが可能です。しかし、TypeScript の機能だけでは変数の内容を実行時に検証することはできません。また、バックエンド側の型に変更が生じた場合の対応も難しい点があります。またtypeofを用いた条件分岐は一つの方法として考えられますが、様々なケースをすべて網羅するのは大変です、、

そこで、Zod の出番です!

ClientComponent.tsx
'use client';

import { useEffect } from 'react';
import z from 'zod';

const userSchema = z.object({
  name: z.string(),
  id: z.number(),
});

const ClientComponent = () => {
  useEffect(() => {
    fetch('/api/user')
      .then((res) => res.json())
      .then((data: unknown) => {
        const validatedUser = userSchema.safeParse(data);

        if (!validatedUser.success) {
          throw new Error('ユーザー情報が不正です');
        }
        console.log(validatedUser.data);
      });
  }, []);

  return <div className=''></div>;
};

export default ClientComponent;

このように Zod を使用してクラッシュを防止し、欠落しているプロパティを処理し、正しいデータ型を確保することで、外部データを受信してもクラッシュしない堅牢なアプリケーションを構築することができます。

safeParse と parse メソッドの違い

直前のコードでは safeParse で検証を行いましたが、parse との違いも見ておきましょう

safeParse:

  • エラーをスローするのではなく、結果オブジェクトを返す。
  • success プロパティをチェックして、バリデーションが成功したか失敗したかを判断する
  • バリデーションが失敗した場合、カスタムエラーメッセージをスローする
.then((data: unknown) => {
  const validatedUser = userSchema.safeParse(data);

  if (!validatedUser.success) {
    throw new Error('ユーザー情報が不正です');
  }
  console.log(validatedUser.data);
});

parse:

  • 入力がスキーマに適合していない場合、parseは直接エラーをスローする
  • エラーハンドリングのためにtry/catchを使用する必要がある
.then((data: unknown) => {
  try {
    const validatedUser = userSchema.parse(data);
    console.log(validatedUser);
  } catch (error) {
    throw new Error('ユーザー情報が不正です');
  }
});

以上より、parse を使用すると、バリデーションエラーが発生した場合に直接エラーを取得することができますが、これを適切に処理するためにtry/catchブロックが必要となるため、
結果オブジェクトを返し、このオブジェクトを介してバリデーションの成功・失敗を容易に確認できる safeParse の活用をおすすめします。

localStorage

以下は localStorage のgetItemメソッドを使用してローカル・ストレージにカート情報を保存し、JSONフォーマットからJavaScriptオブジェクトに変換し、アイテムがNULLまたは空の配列/オブジェクトの場合を処理します。

変数colorは any 型として返ってくるので、color にはアクセスし放題となります。

Zod を使ってカート情報をローカルストレージに保存し、TypeScript を使ってデータにアクセスし、スキーマを作成して制御を強化し、ローカルストレージとURLからデータを検証し、Zod をデータ検証と変換に活用します。

ClientComponent
'use client';

import { useEffect } from 'react';
import z from 'zod';

const shoppingCartSchema = z.object({
  id: z.number(),
  // 正の整数
  quantity: z.number().int().positive(),
});

const ClientComponent = () => {
  const shoppingCart = JSON.parse(localStorage.getItem('cart') || '[]');

  const validatedCart = shoppingCartSchema.safeParse(shoppingCart);

  if (!validatedCart.success) {
    // 必要に応じて古いデータを削除すること等にも使える
    localStorage.removeItem('cart');
  }

  return <div className=''></div>;
};

export default ClientComponent;

URL パラメータ

Zod を使えば、ファイルシステムから受け取ったデータが期待されるスキーマと一致することを確認でき、Server Component では、検索パラメータを使ってURLからデータを読み取ることができます。
例えば、IDを文字列から数字に変換したり、サイズを small, medium, large のみに制限したりといったことが可能です。

Server Component

ServerComponent.tsx
import { parsedEnv } from '@/env';
import { useSearchParams } from 'next/navigation';
import z from 'zod';

const searchParamsSchema = z.object({
  // 強制的に数値に変換
  id: z.coerce.number(),
  size: z.enum(['small', 'medium', 'large']),
});

const ServerComponent = async ({
  searchParams,
}: {
  searchParams: {
    [key: string]: string | string[] | undefined;
  };
}) => {
  const parsedSearchParams = searchParamsSchema.safeParse(searchParams);

  if (!parsedSearchParams.success) {
    return;
  }

  const { id, size } = parsedSearchParams.data;
  // http://localhost:3000/?id=1&size=small にアクセスした場合
  console.log(id, size); // 1 | small

  return <div className=''></div>;
};

export default ServerComponent;

Client Component

Next.jsのuseSearchParamsフックを使ってデータをアプリに読み込み、Zod を使ってスキーマを使用してデータを検証することも可能です。

'use client';

import { useSearchParams } from 'next/navigation';
import z from 'zod';

const searchParamsSchema = z.object({
  id: z.coerce.number(),
  size: z.enum(['small', 'medium', 'large']),
});

const ClientComponent = () => {
  const searchParams = useSearchParams();
  const searchParamsObject = Object.fromEntries(searchParams);

  const validatedSearchParams =
    searchParamsSchema.safeParse(searchParamsObject);

  if (!validatedSearchParams.success) {
    return;
  }

  const { id, size } = validatedSearchParams.data;
  // http://localhost:3000/?id=1&size=small にアクセスした場合
  console.log(id, size);// 1 | small

  return <div className=''></div>;
};

export default ClientComponent;

環境変数

env ファイルで以下の2つの環境変数があるとします。

.env
DATABASE_URL=
API_KEY=

以下のような ts ファイルを用意しておくことで、

env.ts
import { z } from 'zod';

const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  API_KEY: z.string(),
});

export const parsedEnv = envSchema.parse(process.env);

環境変数にも型推論が効いてくれます。

Database(ORM)

これに関しては、パッケージの紹介になります。
PrismaなどのORMは、テーブルから返されるデータの形状を保証します。しかし、エッジケースや直接のSQLクエリによる検証の際には、Zodスキーマの使用が便利です。このようなニーズに応えるために、PrismaスキーマをZodスキーマに変換するツールとしてzod-prisma-typesのようなパッケージが提供されています。これにより、スキーマの重複を避けることができます。

最近だとdrizzleも ORM としての候補の一つかと思いますが、drizzle-zodというプラグインも用意されていました。

まとめ

Zod は React アプリのデータを検証・変換するための強力なツールで、こんな使い方があったんやと参考になれば幸いです。

以上です!

Discussion