zodとは何か
最初は「型のライブラリらしい」「バリデーションもできるらしい」くらいでしかわかっていなかったので、あらためてまとめつつ理解してみる。 あらためて理解してみるシリーズ第2弾である。
TypeScript自体がすでに型を持っているので、さらにライブラリの「Zod」が必要なのかが分かりにくい。
この記事では、どんな問題を解決するためのライブラリかという観点で整理していこうと思う。
Zod は何をするライブラリか
簡単にいうと、Zod は実行時に値を検証するためのライブラリである。
そして、その検証ルールからTypeScriptの型も取り出せる。
例えば次のように書ける。
import { z } from "zod";
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
この定義があると、
idは数値であることnameは文字列であることemailはメール形式であること
を実行時にチェックできる。
さらに、この schema から TypeScript の型も作れる。
type User = z.infer<typeof UserSchema>;
つまり Zod は、「型定義」と「値の検証」をできるだけ同じ場所にまとめるための道具である。
TypeScript の型だけではなぜ足りないのか
TypeScript は便利だが、コンパイル時の型チェックしかしてくれない。
言い換えると、「コードを書く人に対して」安全性を高めてくれるが、「外から入ってくる値が本当に正しいか」は保証することはできない。
例えば API からこんな値が返ってきたとする。
const data = await fetch("/api/user").then((res) => res.json());
この data は、実行時には何が入ってくるか分からない。
TypeScript で User と注釈を付けても、実際の JSON が壊れていたら意味がない。
つまり、次の2つは別物である。
- TypeScript の型
- 開発時にコードの整合性を見る
- Zod の検証
- 実行時に実データが正しいかを見る
TypeScript では int8 や int64 を区別できるのか
ここで一つ気になりやすいのが、数値型の細かい区別である。
TypeScript では、基本的に数値は number として扱われる。
つまり、Java や Go や Rust のように、
int8int32int64float
のような細かい数値型を標準で区別しているわけではない。
このため、TypeScript だけでは「これは 8bit 整数」「これは 64bit 整数」といった表現はほぼできない。
少なくとも、言語機能として自然に区別できるわけではない。
では Zod を使うとどうなるか。
実行時の制約としてはかなり表現できるが、TypeScript の型そのものが増えるわけではない。
例えば int8 的な制約なら、こう書ける。
const int8Schema = z.number().int().min(-128).max(127);
const uint8Schema = z.number().int().min(0).max(255);
これで、
- 整数であること
- 範囲内であること
は検証できる。
ただし、z.infer<typeof int8Schema> で得られる型は、実質的には number である。
つまり Zod がやってくれるのは、
numberの中での制約チェック- 値がその範囲に収まるかの検証
であって、TypeScript の型システムに int8 という新しい数値型を追加してくれるわけではない。
int64 は特に注意が必要
int64 のような大きい整数を扱うときは、さらに注意が必要である。
JavaScript の number は 64bit 整数を完全に安全に扱えるわけではなく、安全整数の範囲に制限がある。
そのため、バックエンドが int64 を返してくるときは、次の2パターンを分けて考えた方がよい。
1. 安全整数の範囲に収まる前提
この場合は、普通に z.number().int() で扱ってもよいことが多い。
const idSchema = z.number().int();
2. 大きい ID や long 値が来る可能性がある
この場合は、文字列として受ける方が安全なことがある。
const int64AsStringSchema = z.string().regex(/^-?\d+$/);
API の設計によっては、巨大な整数を JSON 上では文字列で返すことも多い。
このケースでは、フロントで無理に number にせず、まず文字列として検証し、必要ならあとで BigInt などへ変換する方が安全である。
つまり Zod で数値制約はかなり書けるが、JavaScript 自体の数値表現の限界は超えられない、という点は注意が必要である。
Zod が活躍する場所
Zod は「外から値が入ってくる境界」で特に強い。
例えば次のような場面で便利である。
- フォーム入力
- URL クエリパラメータ
- API レスポンス
- API リクエスト body
- 環境変数
- CMS や Markdown の frontmatter
どれも共通しているのは、コードの外から値が渡されるという点である。
業務で何度も上記のような、想定している型でない理由のバグに遭遇した。
確認すればよいのだが、案外思い込みによりすぐに気づけない場合も多々あるので、できる限り人的チェックでないものではじけるようにしたい。
バックエンドから渡される値の型チェックにも使えるのか
フロントエンドでは、バックエンドから返ってくる JSON をつい信用してしまいがちである。
しかし実際には、次のようなズレは普通に起こる。
numberのはずが文字列で返ってくるnullが来ない前提なのにnullが返る- フィールド名が一部変わっている
- 配列のはずがオブジェクトになっている
- バージョン違いでレスポンス形式が変わる
TypeScript の型注釈だけでは、このズレは防げない。
なぜなら fetch().json() の結果は、実行時にはただの不確定な値だからである。
例えば次のように書く。
const UserSchema = z.object({
id: z.number().int(),
name: z.string(),
email: z.string().email(),
});
const json = await fetch("/api/user").then((res) => res.json());
const result = UserSchema.safeParse(json);
if (!result.success) {
console.error(result.error);
} else {
console.log(result.data);
}
このようにすると、「バックエンドが返した値が本当に想定どおりか」を受け取った瞬間に確認できる。
そのため、フロントエンドとバックエンドの境界で起きる不整合を早めに見つけやすい。
特に次のような場面ではかなり有効である。
- 自前 API を叩く画面
- 外部 API を叩く処理
- BFF から値をもらうフロント
- 管理画面の一覧取得
- CSV / JSON インポート
バックエンドが TypeScript で書かれているかどうかに関係なく、受け取った側で最後に検証するという意味で Zod は価値がある。
フロントとバックで schema を共有する考え方
さらに進むと、フロントとバックエンドで同じ schema を共有したくなる。
これはかなり理にかなっている。
例えば同じ Zod schema を共有できれば、
- リクエストの検証
- レスポンスの検証
- フォーム入力の検証
- TypeScript の型推論
をある程度一本化できる。
ただし、最初から全部共通化しようとすると設計が重くなりやすい。
まずは「境界の入力値を Zod で守る」くらいから始める方が現実的である。
パースするという考え方
Zod を使うときは、「validate する」より「parse する」と考えるとしっくりくる。
const user = UserSchema.parse(data);
これは「data が UserSchema に合っているなら、その形として扱う。合っていなければエラーにする」という意味になる。
もし例外を投げたくなければ safeParse も使える。
const result = UserSchema.safeParse(data);
if (!result.success) {
console.error(result.error);
} else {
console.log(result.data);
}
この safeParse がかなり便利で、フォームや API ハンドラで特に使いやすい。
成功時と失敗時が明確に分かれるので、エラーハンドリングを素直に書ける。
どこまで厳しく書くべきか
Zod を使い始めると、何でも厳密に書きたくなることがある。
ただ、最初から細かすぎる schema を作ると運用が重くなる。
まずは次のような基本からで十分である。
- 文字列か
- 数値か
- 配列か
- オブジェクトか
- 必須か任意か
そこから必要に応じて、
.email().min().max().url().regex().refine()
のような条件を足していくとよい。
最初から完璧な schema を目指すより、「壊れやすい境界だけ先に守る」方が実務では進めやすい。
TypeScript の型を Zod にするのか、Zod から型を作るのか
ここもよく迷うところである。
結論からいうと、外部入力の境界では Zod を先に書いて、型はそこから作る 方が分かりやすいことが多い。
理由は単純で、外から来る値で大事なのは「型定義」そのものよりも「本当にその値を受け入れてよいか」だからである。
つまり、schema が主役で、型は副産物でよい。
一方で、内部ロジックだけの型は普通に TypeScript の type や interface を書けば十分なこともある。
Astro でも Zod は相性が良い
Zod は React や Next.js だけのライブラリではない。
Astro のような構成でもかなり使いやすい。
このブログでも、記事の frontmatter は Zod ベースで定義している。
例えば Content Collections の schema では、
titleは文字列publishedAtは日付tagsは文字列配列draftは真偽値
といったルールを先に決めている。
これにより、記事を書くときのミスを早い段階で見つけやすくなる。
個人ブログのように「未来の自分」が利用者になる構成ほど、こういう仕組みは地味に効く。
Zod を使うときの注意点
便利なライブラリだが、何でも Zod に寄せればよいわけではない。
注意したいのは次の点である。
1. schema を複雑にしすぎない
複雑すぎる schema は、逆に読みづらくなる。
特に .refine() を多用しすぎると、何を保証しているのか追いにくい。
2. 内部データまで全部 parse しない
外部入力の境界では有効だが、完全に内部で生成した値まで毎回 parse すると冗長になることがある。
3. エラーメッセージ設計は別途考える
Zod のエラーは開発には便利だが、そのままユーザー向け表示に使うには不親切なこともある。
フォームでは整形が必要になる場合が多い。
4. 数値型の厳密さを過信しない
Zod で int8 的な制約や範囲制約は書けるが、JavaScript の number 自体が別の数値型になるわけではない。
特に int64 相当の大きい値は、文字列で扱うべきケースがある。
まとめ
Zod は、型っぽい記法で書ける便利ライブラリというより、実行時に値を信頼してよいかを確認するための道具と理解すると分かりやすい。
大事なのは次の点である。
- TypeScript の型と、実行時の値検証は別物
- Zod は外部入力の境界で特に強い
- schema から型を作れるので、定義の重複を減らせる
- Astro の frontmatter や API、フォームでも使いやすい
TypeScript を使っていると、型があるだけで安全に見えてしまう。
でも実際には、API やフォームや環境変数のような「外から来る値」が一番壊れやすい。
その境界を守るために Zod を使う、と考えるとかなり整理しやすいと思う。
関連記事
Reactの状態管理を理解する
状態管理という言葉が何を指しているのか、なぜ必要になるのかを自分なりに整理する。
Astro で個人ブログを始めるときに最初に決めたこと
Astro と Cloudflare Pages を前提に、個人ブログの初期設計で決めたことをまとめる。