Type だけじゃダメ?Zod / Valibot が必要になる理由を整理する
「普通に type で型をつければよくない?」
TypeScript を触っていると、一度はこう思うはずです。 そしてこれはかなり本質的な問いで、「どこまでを TypeScript に任せて、どこからを Zod / Valibot みたいなライブラリに任せるべきか?」という設計の話につながります。
この記事では、ざっくり次のあたりを整理します。
- TypeScript の
type/interfaceが守ってくれる範囲 - なぜ実行時バリデーション(Zod / Valibot)が必要になるのか
- どこまでは
typeだけでよくて、どこからはスキーマライブラリを使うとコスパが良いか - Zod / Valibot のざっくり比較と、実務での使い分けパターン
- 既存の
typeベースのコードから徐々に移行するステップ
このブログの前提
- TypeScript の基本文法(
type/interface/Promiseなど)は既に分かっている想定 - React / API クライアントを触ったことがある人をイメージ
- ランタイムバリデーションは「テストの話」ではなく「プロダクションコードの話」として扱う
1. TypeScript の型は “図面”、実データは誰も見てない
まず一番大事な前提。
TypeScript の型はコンパイル時だけ存在して、実行時には消える
です。
シンプルな例
type User = {
id: string;
name: string;
};
function greet(user: User) {
console.log(`Hello, ${user.name.toUpperCase()}`);
}
// どこかで…
const data = JSON.parse('{"id": 123, "name": null}');
greet(data as User); // コンパイルは通るが、実行時に落ちうる
ポイントはここです。
data as Userと書いた瞬間、TypeScript は「そうなんだ」と信じてしまう- 実データが
{ "id": 123, "name": null }でも、コンパイル時には一切怒らない - そして
user.name.toUpperCase()で実行時に落ちる可能性がある
つまり type / interface は、
- 「この関数には User 型の値が渡ってくるはず」という約束を表現しているだけで、
- 「本当に User か」を実行時にチェックしているわけではない
という役割です。
unknown / any の違いとか、strictNullChecks みたいな設定も、あくまでコンパイル時の話。
一度 JavaScript にトランスパイルされてしまえば、型情報はきれいさっぱり消えています。
2. 「外部との境界」で type は無力になりやすい
問題が露骨に出るのは、アプリケーションの外部とデータをやりとりするところです。
典型的にはこの辺。
- HTTP API のレスポンス / リクエスト
- フロントのフォーム入力
- 設定ファイル(JSON / YAML)
localStorage/IndexedDBprocess.envや環境変数- 外部サービス / SaaS から飛んでくる Webhook やイベント
ざっくり言うと「自分ではコントロールできない世界」から入ってくるデータたち。
API レスポンスに type をつけるだけだと?
type User = {
id: string;
name: string;
};
async function fetchUser(): Promise<User> {
const res = await fetch("/api/user");
const json = await res.json();
return json as User; // or `return json;` でもコンパイルは通る
}
ここで、もしバックエンド側の誰かがレスポンスを変えてしまったら?
{ "user_id": 1, "full_name": "Alice" }
という JSON を返すようになっても、
- TypeScript 的には 何も気づかない のでコンパイルは普通に通る
user.nameを読んだところでundefinedになり、その先でバグる
type はあくまで図面だけで、
届いた部品(レスポンス)が図面通りかどうかを確認してくれる人はいない
という状態になっています。
3. Zod / Valibot の役割:図面 + 検査官を 1 つにまとめる
そこで出てくるのが Zod と Valibot みたいなスキーマライブラリです。
どちらもざっくり言うと、
スキーマ(Schema)を 1 回書けば
- TypeScript の型定義
- 実行時のバリデーション
の両方をそこから自動で得られる
という思想のライブラリです。
- Zod は「TypeScript-first」なスキーマ宣言 & バリデーションライブラリで、スキーマから型推論と実行時検証を両方提供します。(Zod)
- Valibot は TypeScript 向けのスキーマライブラリで、モジュール式 & ツリーシェイカブルな設計により、極小のバンドルサイズと型安全性を両立しています。(valibot.dev)
3.1 Zod の基本
import { z } from "zod";
const UserSchema = z.object({
id: z.string(),
name: z.string(),
});
// スキーマから型を推論
type User = z.infer<typeof UserSchema>;
async function fetchUser(): Promise<User> {
const res = await fetch("/api/user");
const json = await res.json();
// ここで実行時に検査
const parsed = UserSchema.parse(json); // 失敗したら例外が投げられる
return parsed; // parsed は User 型として安全
}
ポイントは 2 つ。
UserSchemaが「User の図面」であり、「User を検査する関数」でもあるparseを通った時点で、実データがスキーマ通りであることが保証される
parse は失敗すると例外を投げるので、API ハンドラなどでは safeParse を使って結果オブジェクトで扱う書き方もよくやります。
const result = UserSchema.safeParse(json);
if (!result.success) {
// result.error にどのフィールドがダメか詳細が入っている
console.error(result.error.format());
throw new Error("invalid response");
}
const user: User = result.data;
Zod はプリミティブ・配列・Union・Intersection・Refine/Transform・JSON Schema 変換など、ひと通り揃っています。(Zod)
3.2 Valibot の基本
Valibot もやっていることはほぼ同じですが、API のスタイルが少し違います。
import * as v from "valibot";
const UserSchema = v.object({
id: v.string(),
name: v.string(),
});
// スキーマから出力型を推論
type User = v.InferOutput<typeof UserSchema>;
async function fetchUser(): Promise<User> {
const res = await fetch("/api/user");
const json = await res.json();
// 失敗すると ValiError を投げる
return v.parse(UserSchema, json);
}
Valibot の特徴的なところは pipe による関数合成スタイルです。(valibot.dev)
const LoginSchema = v.object({
email: v.pipe(v.string(), v.email()),
password: v.pipe(v.string(), v.minLength(8)),
});
type LoginInput = v.InferOutput<typeof LoginSchema>;
const result = v.safeParse(LoginSchema, unknownInput);
if (result.success) {
const data: LoginInput = result.output;
} else {
console.log(result.issues);
}
string(),email(),minLength()のような小さな関数をpipeでつなぎ、- それを
parse/safeParseで実行時に検証 & 型付け
という形です。Valibot はモジュールごとに import するので、使っている関数だけがバンドルに入る設計になっています。(valibot.dev)
4. type だけで十分な場所 / 足りなくなる場所
全部に Zod / Valibot を刺す必要はありません。 現実的には、次のように切り分けるとコスパが良いです。
4.1 type だけで十分なケース
-
アプリ内部だけで完結しているデータ
- 例:サービス層内で定義したドメインオブジェクト (生成も利用も自分のコードだけで完結している)
-
すでに「入口」でバリデーション済みの中流のデータ
- 例:HTTP ハンドラ入口で Zod で
parse→ 以降はtypeベースで処理
- 例:HTTP ハンドラ入口で Zod で
-
小さなスクリプト・テストコード
- 壊れても範囲が限定されていて、多少のクラッシュは許容できる場合
この辺は「図面通りのものしかそもそも作らない」世界なので、type 頼みでも破綻しにくいです。
4.2 Zod / Valibot が欲しくなるケース
- 外部 API のレスポンス / リクエスト
- フロントエンドのフォーム入力(文字列 → number / date / email など)
- 設定ファイル(JSON / YAML)
process.env(必須 / 任意、型、範囲などのチェック)localStorage/IndexedDBに出し入れするデータ- 外部サービス / SaaS から飛んでくる Webhook やイベント
まとめると、
「発生源を自分で完全にはコントロールできないデータ」にはスキーマライブラリを噛ませる価値が高い
という整理になります。
5. 手書きバリデーションで頑張る場合との比較
「いや、Zod / Valibot 使わずに、普通に type guard 書けば良くない?」という選択肢もあります。
手書きでやるとこうなる
type User = {
id: string;
name: string;
};
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
typeof (value as any).id === "string" &&
typeof (value as any).name === "string"
);
}
function parseUser(value: unknown): User {
if (!isUser(value)) {
throw new Error("Invalid user");
}
return value;
}
これでも一応動きますが、
type UserとisUserで 同じ情報を二重管理 しているUserを変更したときに、isUserの修正漏れが起きうる- ネスト・配列・Union などが増えると
isXxxが一気に地獄化する - どのフィールドがダメだったか、エラーメッセージを整形するのも全部自前実装
というつらさが出てきます。
Zod / Valibot は要するに、
「型定義 + バリデーションロジック」を宣言的に一箇所にまとめるための DSL
だと思っておくと、採用判断がしやすくなります。
6. Zod vs Valibot をざっくり比較
細かい API の違いはドキュメントに任せるとして、実務で気になるところだけざっくり。
6.1 Zod
- TypeScript-first なスキーマ宣言 & バリデーションライブラリ
- プリミティブ / オブジェクト / Union / Intersection / transform / refine など一通り揃っている
- スキーマから TypeScript 型を自動推論(
z.infer)(Zod) - JSON Schema 変換などの周辺機能もある(GitHub)
- tRPC / React Hook Form / Hono などとの統合がかなり豊富で、「デファクトっぽい」ポジション (kitchen-sink.trpc.io)
6.2 Valibot
- TypeScript 向けのスキーマライブラリ
- 小さな関数を
pipeでつなぐ関数型スタイル - スキーマから出力型・入力型を推論(
InferOutput/InferInput) - モジュール式 & ツリーシェイカブルで、バンドルサイズが非常に小さい(コアが < 1kB クラス)(valibot.dev)
parse/safeParse/is/assertなど Zod と似た API も提供(valibot.dev)- Hono OpenAPI などとの連携や、Prisma/Drizzle/GraphQL から Valibot スキーマを生成するエコシステムも育ってきている (valibot.dev)
ざっくりまとめると、
- DX / エコシステム重視 → Zod
- バンドルサイズ / パフォーマンス重視 → Valibot
という選び方がしやすいです。
7. 実務での使い分けパターン(API + フロントの一例)
実際のプロジェクトだと、だいたいこんな配置になることが多いです。
7.1 バックエンド(REST API サーバー)の例:入口だけ Zod
Express っぽい API サーバーを想像します。
import express from "express";
import { z } from "zod";
const CreateUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
type CreateUserInput = z.infer<typeof CreateUserSchema>;
const app = express();
app.use(express.json());
app.post("/users", async (req, res) => {
const result = CreateUserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
message: "Invalid request body",
errors: result.error.format(),
});
}
const input: CreateUserInput = result.data;
// ここから先は "内部の世界"
// type / interface だけで気持ちよく書ける
const user = await createUser(input);
res.status(201).json(user);
});
このパターンの考え方はシンプルで、
- 「外から入ってくる境界」= Zod / Valibot でガッチリ固める
- 一度バリデーションを通したら、その中は普通に
type/interfaceで扱う
という二段構えです。
tRPC のような RPC 系のフレームワークでも、入力スキーマに Zod を渡して、同じスキーマをフロントでも使い回す、という使い方がよくされています。(kitchen-sink.trpc.io)
7.2 フロントエンドのフォーム + Valibot の例
フロント側のフォームは、ユーザー入力という最強の「信用ならないデータ源」なので、ここにもスキーマを噛ませると安心です。
import * as v from "valibot";
const LoginSchema = v.object({
email: v.pipe(
v.string(),
v.email("メールアドレスの形式が正しくありません")
),
password: v.pipe(
v.string(),
v.minLength(8, "8文字以上で入力してください")
),
});
type LoginInput = v.InferOutput<typeof LoginSchema>;
React コンポーネント側では、safeParse を使うと扱いやすいです。
function LoginForm() {
const [errors, setErrors] = useState<Partial<Record<keyof LoginInput, string>>>(
{}
);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const raw = {
email: formData.get("email"),
password: formData.get("password"),
};
const result = v.safeParse(LoginSchema, raw);
if (!result.success) {
// Valibot の issues をいい感じに整形して setErrors する
const fieldErrors: Partial<Record<keyof LoginInput, string>> = {};
for (const issue of result.issues) {
const path = issue.path?.[0]?.key as keyof LoginInput | undefined;
if (path) fieldErrors[path] = issue.message;
}
setErrors(fieldErrors);
return;
}
const data = result.output; // LoginInput 型として安全
setErrors({});
// ここで fetch("/api/login", { body: JSON.stringify(data) }) など
};
return (
<form onSubmit={handleSubmit}>
{/* input + errors.email の表示… といった感じ */}
</form>
);
}
- 入力 → Valibot で検証 → OK なら API へ
- サーバー側でも同じ Valibot スキーマを使って二重にチェック、という構成も取りやすいです
8. 既存 type 定義から Zod / Valibot に移行するステップ
「プロジェクトの途中から導入したい」ケースがほとんどだと思うので、移行ステップも軽く整理しておきます。
ステップ 1: 「境界」を洗い出す
まずはコードベースをざっと眺めて、
- 外部 API を叩いているところ
- フォームの submit
process.envを触っているところ- 設定ファイルを読み込んでいるところ
localStorage/sessionStorage/IndexedDBに出し入れしているところ
など、「外との境界」をリストアップします。
全部を一気に変えようとせず、壊れるとまずいところ / 触る頻度が高いところから手を付けるのがおすすめです。
ステップ 2: 既存 type からスキーマを起こす
たとえば、こんな type が既にあるとします。
// 既存
type User = {
id: string;
name: string;
};
最初の一歩としては、「型を変えずにスキーマだけ追加する」でも十分です。
import { z } from "zod";
type User = {
id: string;
name: string;
};
const UserSchema = z.object({
id: z.string(),
name: z.string(),
});
Valibot ならこう。
import * as v from "valibot";
type User = {
id: string;
name: string;
};
const UserSchema = v.object({
id: v.string(),
name: v.string(),
});
この段階では、User をそのまま使い続けても OK です。
ステップ 3: as をやめて parse / safeParse に差し替える
よくあるパターン:
// Before
async function fetchUser(): Promise<User> {
const res = await fetch("/api/user");
const json = await res.json();
return json as User;
}
これをスキーマ経由に置き換えます。
// After (Zod)
async function fetchUser(): Promise<User> {
const res = await fetch("/api/user");
const json = await res.json();
return UserSchema.parse(json);
}
例外で落ちたくない箇所は safeParse にして、エラーを握りつぶすか、いい感じに整形して返すようにします。
Valibot 版もほぼ同じです。
// After (Valibot)
async function fetchUser(): Promise<User> {
const res = await fetch("/api/user");
const json = await res.json();
return v.parse(UserSchema, json);
}
ステップ 4: 「型の源泉」をスキーマに寄せる
慣れてきたら、type → スキーマの順ではなく、スキーマ → 型 の順に切り替えると二重管理が減ります。
// Before
type User = {
id: string;
name: string;
};
const UserSchema = z.object({
id: z.string(),
name: z.string(),
});
を、
// After
const UserSchema = z.object({
id: z.string(),
name: z.string(),
});
type User = z.infer<typeof UserSchema>;
にするイメージです。Valibot なら v.InferOutput<typeof UserSchema>。(valibot.dev)
この状態になると、
- スキーマを書き換える
- それに応じて TypeScript の型も自動で更新される
ので、「スキーマと型がズレる」という事故が起きにくくなります。
ステップ 5: 余裕があれば env / 設定ファイルも固める
最後に、落ちると地味につらい process.env や設定ファイルもスキーマ化しておくと安心です。
// Zod の例
const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]),
DATABASE_URL: z.string().url(),
});
const env = EnvSchema.parse(process.env);
export type Env = z.infer<typeof EnvSchema>;
Valibot 版も同じように組めますし、safeParse + fallback を使えば「壊れたらデフォルト値で動かす」みたいな設計も取りやすいです。(valibot.dev)
まとめ
最後に、この記事の内容をざっくり整理すると:
-
TypeScript の
type/interfaceは コンパイル時の約束 であり、実行時のデータを検査してくれるわけではない -
外部から入ってくるデータ(API, フォーム, env, 設定ファイル…)に
typeを付けるだけだと、「そうであって欲しい」と信じているだけ になりがち -
Zod / Valibot は、スキーマを 1 回書くだけで「型定義」と「実行時バリデーション」を両取りする ための仕組み → 「図面」と「検査官」を 1 つにまとめるイメージ
-
現実的には:
- 内部の世界(自分でしか触らないデータ)
→
type/interfaceだけで済ませて OK なことが多い - 外部との境界(API, フォーム, env, 設定ファイル, Webhook など)
→ Zod / Valibot などで一度
parse/safeParseしてから中に流す
- 内部の世界(自分でしか触らないデータ)
→
-
ライブラリ選定の目安はざっくりこんな感じ:
- エコシステム・情報量・安心感なら Zod (tRPC / React Hook Form などとの連携も豊富)(kitchen-sink.trpc.io)
- バンドルサイズやエッジ環境のパフォーマンスを突き詰めるなら Valibot (モジュール式 & ツリーシェイカブルで非常に小さい)(valibot.dev)
あとは、既存プロジェクトならいきなり全部を置き換えず、
- 「外との境界」を洗い出す
- クリティカルなところからスキーマを追加する
asキャストを少しずつparse/safeParseに置き換える- 慣れてきたら「スキーマ → 型」の順に寄せていく
という感じで、少しずつ守備範囲を広げていくのが現実的かなと思います。
ここまで読んで「うちのコードベースだと、どこから手を付けるのが良さそうか?」がなんとなくイメージできていれば、この話はいったんゴールです。