本文へスキップ
ウェブエンジニア問題集
第16章

Zodの実務パターン — スキーマの置き場所と設計の勘所

約7分
この章の目次開く

ここまでは「どう書くか」でした。最後に「どこに置くか・どこまでやるか」という、コードの見た目には出にくい判断をまとめます。

スキーマの置き場所

境界ごとに分けて、共通部分は派生させる

同じリソースでも、用途によって必要なスキーマは違います。

// src/schemas/user.ts
export const userSchema = z.object({
  id: z.number(),
  name: z.string().trim().min(1),
  email: z.email(),
});
 
export const createUserSchema = userSchema.omit({ id: true });
export const updateUserSchema = createUserSchema.partial();
ts
元になるスキーマを1つ決め、そこから .omit() や .partial() で派生させる。項目が増えたときに直す場所が1か所で済みます。

クライアントとサーバーで共有する

TypeScriptでフロントもバックエンドも書いているなら、スキーマファイルを両方から読み込みます。同じルールを2回書かなければ、ずれようがありません。

src/schemas/user.ts
  ├─ components/UserForm.tsx から import(画面の検証)
  └─ app/api/users/route.ts から import(サーバーの検証)

どこまで検証するか

Zodに慣れると、あらゆる場所にスキーマを書きたくなります。しかし検証はタダではありません。実行時のコストと、コードの読みにくさが増えます。

場所検証するか
外部APIのレスポンスする
リクエストボディ・フォームする
環境変数(起動時)する
JSON.parse / localStorageする
自分のコード内で組み立てたオブジェクトしない
検証済みデータを渡す内部関数しない
境界で1回止めたら、その先は普通のTypeScriptの型で扱う。これがZodの基本の使い方です。
学習者学習者

関数の入り口ごとに検証しておけば安心じゃない?

先生先生

気持ちは分かるけど、それは型が既に保証していることの二度手間なんだ。内部関数まで parse だらけにすると、本当に危ないのはどこかが読み取れなくなる。信頼境界を1本引いて、そこだけ守る方が結果的に安全だよ。

道を歩いている人のイラスト

大きなデータの検証コスト

数万件の配列をそのまま parse すると、要素の数だけ検証が走ります。体感できる遅さが出ることがあります。対処の考え方です。

  • 必要な項目だけスキーマに書く。 使わないフィールドまで検証しない(余分なキーは既定で取り除かれます)
  • ページングされた単位で検証する。 全件をまとめて検証しない
  • 信頼できる内部データは検証しない。 自前のDBから型付きで取れているなら不要な場合がある

パフォーマンスは推測せず、実際に測ってから判断してください。多くのアプリでは、Zodの検証がボトルネックになる前にネットワークやDBが先に効いてきます。


他のライブラリとの関係

ライブラリ特徴
ZodTypeScript前提。型推論が強く、情報量が多い
Valibot関数を組み合わせる形。バンドルサイズが小さい
Yup歴史が長い。JavaScript時代からのフォーム検証
JSON Schema言語非依存の標準仕様。Fastifyなどが採用

Zod自体も、バンドルサイズを気にする場合向けに zod/mini を同梱しています。


実務で効く小さなパターン

1. 環境変数は起動時に1回だけ

変換の章のとおり、env.ts で parse して、アプリからはその戻り値だけを使います。process.env を直接読む場所をなくすのが目的です。

2. スキーマから選択肢を作る

const status = z.enum(['draft', 'published']);
// 画面のセレクトボックスは status.options から生成する
ts

選択肢の定義がスキーマ1か所になり、画面と検証がずれません。

3. エラーメッセージは書くときに添える

後からまとめて日本語化しようとすると、必ず漏れます。.min(1, 'メッセージ') を書く癖をつけるのが結局いちばん早いです。

4. 検証結果は必ず戻り値を使う

.trim() や .default() の結果は戻り値にしか反映されません。result.data を使ってください。

よくあるハマりどころ(総まとめ)

症状原因
必須項目が空文字で通るz.string() は "" を通す。.trim().min(1) にする
送った項目が保存されないz.object() が未知のキーを黙って捨てている
.optional() なのに null で落ちるnull は .nullable() の担当。両方なら .nullish()
フォームの空欄が 0 になるz.coerce.number() が "" を 0 にする
FLAG=false が効かないz.coerce.boolean() は "false" を true にする。z.stringbool() を使う
日本語にしたのに英語のままv3の required_error などは無視される
エラーが画面に出ないrefine に path を指定していない
検証したのに変換が効かない戻り値ではなく元の変数を使っている

ちゃんと使うためのポイント

  • スキーマは境界ごとに用意し、共通部分から .omit() / .partial() で派生させる
  • 境界で1回止めたら、その先は普通の型で扱う。内部まで検証で埋めない
  • 検証コストは推測せず測る。まず「使う項目だけ書く」のが効く
  • エラーメッセージは後回しにせず、書くときに添える

次の章は、本書の内容を引ける形に圧縮した Zodチートシート です。作業中に開く前提でまとめています。

参考リンク

TypeScriptクイズに挑戦するこの章で学んだ型と検証の知識を、4択クイズでアウトプットして定着させよう