API開発でZodを使ったバリデーションを実装していると、ふと悩んでしまう
「z.string()って空文字通すんだっけ?」「nullとundefinedってどっちがデフォルトで弾かれるんだっけ?」—
Zodの主要な型ごとにデフォルトで何を許容し、何を弾くのかを整理
そもそもnull・undefined・空文字は何が違うのか
バリデーションの話に入る前に、この3つの違いを明確にしておく。JavaScriptに慣れていても、意外と曖昧なまま使っている人は多い。
nullは「値が存在しないこと」を明示的に表す。開発者が意図的に「空」を設定した状態。undefinedは「値がまだ定義されていない」状態。オブジェクトに存在しないプロパティにアクセスしたときや、関数の引数を省略したときに現れる。""(空文字) は立派なstring型の値。長さが0なだけで、型としては文字列。
この区別がバリデーションで重要になる理由は明快で、APIのリクエストボディでは3つすべてが飛んでくる可能性があるからだ。フォームの未入力フィールドは空文字になることが多いし、オプショナルなフィールドはそもそもキーごと省略されてundefinedになる。nullはフロントエンドのステート管理から紛れ込むことがある。
以下の図で、3つの関係を整理しておく。
Zodの共通ルール——null・undefinedは全型で弾かれる
Zodの全型に共通するデフォルト挙動がある。nullもundefinedも、デフォルトでは許容しない。これはstring、number、boolean、enum、array、objectすべてに当てはまる。
import { z } from 'zod';
z.string().parse(null); // ZodError
z.string().parse(undefined); // ZodError
z.number().parse(null); // ZodError
z.boolean().parse(undefined); // ZodError
許容したい場合は、明示的にメソッドを付ける。
// nullを許容
z.string().nullable() // string | null
// undefinedを許容
z.string().optional() // string | undefined
// 両方許容
z.string().nullish() // string | null | undefined
この設計は理にかなっている。バリデーションライブラリとして「明示しない限り厳格」であることは、予期しないデータの混入を防ぐ最良の方針だ。
型別のデフォルト挙動
ここからが本題だ。共通ルール以外に、各型には「その型固有の境界値」がある。空文字、0、空配列、falseなど、弾くべきか許容すべきか判断が分かれる値だ。
z.string()
| 値 | デフォルト | 弾くには |
|---|---|---|
null | 弾く | — |
undefined | 弾く | — |
"" (空文字) | 許容する | .min(1) |
空文字が通るというのは、最初に引っかかるポイントだろう。z.string()は「string型であるか」だけを検査し、中身の長さは検査しない。必須入力フィールドのバリデーションでは.min(1)を忘れないこと。
const requiredString = z.string().min(1);
requiredString.parse(''); // ZodError
requiredString.parse('ok'); // 'ok'
z.number()
| 値 | デフォルト | 弾くには |
|---|---|---|
null | 弾く | — |
undefined | 弾く | — |
NaN | 弾く | — |
0 | 許容する | .positive() または .min(1) |
NaNは常に弾かれる。許容するオプションは存在しない。これはNaNが「数値ではない」ことを示す特殊値であり、バリデーションを通すべきものではないからだ。
0は許容される。IDや金額など、0を弾きたいケースでは.positive()や.min(1)を使う。
const positiveNumber = z.number().positive();
positiveNumber.parse(0); // ZodError
positiveNumber.parse(1); // 1
z.boolean()
| 値 | デフォルト | 弾くには |
|---|---|---|
null | 弾く | — |
undefined | 弾く | — |
false | 許容する | .refine(v => v === true) |
booleanに関しては、falseを弾きたい場面はまずない。チェックボックスの同意確認など、trueのみ受け付けたい場合は.refine()で対応する。
const mustBeTrue = z.boolean().refine(v => v === true, {
message: '同意が必要です',
});
z.enum()
| 値 | デフォルト | 弾くには |
|---|---|---|
null | 弾く | — |
undefined | 弾く | — |
| 定義外の値 | 弾く | — |
"" (空文字) | 定義になければ弾く | — |
enumは定義した値だけを許容する。空文字も、定義に含めない限り弾かれる。最も厳格な型と言える。
const status = z.enum(['active', 'inactive']);
status.parse('active'); // 'active'
status.parse('other'); // ZodError
status.parse(''); // ZodError
z.array()
| 値 | デフォルト | 弾くには |
|---|---|---|
null | 弾く | — |
undefined | 弾く | — |
[] (空配列) | 許容する | .min(1) |
空配列の扱いはz.string()の空文字と同じ発想だ。「配列型であるか」のみを検査し、要素数は問わない。
const nonEmptyArray = z.array(z.string()).min(1);
nonEmptyArray.parse([]); // ZodError
nonEmptyArray.parse(['a']); // ['a']
以下の図で、型ごとの「許容される境界値」を一覧にする。
pipe()は「変換後の値」を検証するための機能
Zodを使っていると、.string().email()のようにメソッドチェーンでバリデーションを組み立てる場面がほとんどだと思います。
この書き方で事足りているうちは、pipe()の存在意義がわかりにくいかもしれません。
pipe()が必要になるのは、transform()で値の型が変わったあと、その変換結果に対してさらにバリデーションをかけたいときです。
逆に言えば、transform()を使わない限りpipe()の出番はほぼありません。
そもそもZodのスキーマは「入力→出力」のパイプライン
pipe()を理解するには、Zodのスキーマが単なるバリデーターではなく「入力を受け取って出力を返すパイプライン」として設計されている点を押さえる必要があります。
const schema = z.string();
schema.parse("hello"); // OK → "hello" が返る
schema.parse(123); // NG → ZodError(例外)parse()は入力をスキーマに通し、OKならその値を出力として返し、NGなら例外をスローします。z.string()のように型が変わらないスキーマでは入力と出力が同じなので、この「出力」という概念を意識する機会がありません。
ところがtransform()を使うと、入力と出力の型が変わります。
const schema = z.string().transform((val) => val.length);
schema.parse("hello"); // "hello"(string) → 5(number)この場合、スキーマの入力型はstringですが、出力型はnumberです。pipe()はこの出力を次のスキーマの入力として渡す仕組みです。
pipe()が必要になるケース
transform()で型が変わったあと、変換結果を既存のスキーマで検証したいときに使います。
文字列を数値に変換して範囲チェック
const Age = z.string()
.transform(Number)
.pipe(z.number().int().min(1).max(120));
Age.parse("25"); // → 25
Age.parse("200"); // → エラー(120超え)
Age.parse("abc"); // → エラー(NaNはintを満たさない)フォームの入力値やクエリパラメータは基本的にすべて文字列で届きます。
それを数値に変換してからバリデーションをかけるパターンは実務で頻出します。
safeParse で挙動を確認する
スキーマの挙動に自信がないときは、safeParseを使って確認するのが確実だ。parseと違い、バリデーション失敗時に例外を投げずに結果オブジェクトを返す。
safe() safeParse の違い は「オブジェクトを throw するか、return するか」です。
制御フローの差であって、ZodError というエラーオブジェクトです。
使い分けとしては、フォーム入力など失敗が想定内なら .safeParse()、外部APIレスポンスの検証など失敗が想定外で即座に中断したいなら .parse() が素直です。
const schema = z.string().min(1);
const result = schema.safeParse('');
console.log(result.success); // false
console.log(result.error?.issues);
// [{ code: 'too_small', minimum: 1, ... }]
ターミナルからワンライナーで確認することもできる。
npx tsx -e "const {z} = require('zod'); console.log(z.string().min(1).safeParse(''))"
テストとして書いておけば、スキーマを変更したときのリグレッション防止にもなる。
describe('ユーザー名バリデーション', () => {
const username = z.string().min(1);
test('空文字は弾く', () => {
expect(username.safeParse('').success).toBe(false);
});
test('nullは弾く', () => {
expect(username.safeParse(null).success).toBe(false);
});
test('通常の文字列は通す', () => {
expect(username.safeParse('taro').success).toBe(true);
});
});

as と zod の parse
データの出所が自分のコード内部で型が確実 → asでも問題ない。データの出所が外部(API、DB、フォーム、ファイル等) → parse系を使うべき。
as(型アサーション)
コンパイル時にしか効果がない。実行時には何もしない。つまり型の嘘をつける。使っていいのは「自分が型を確実に知っているが、TypeScriptの推論が追いつかない場面」に限る。例えばDOM操作でdocument.getElementById("x") as HTMLInputElementのような、構造的に確実な場合。
外部からのデータ(API応答、ユーザー入力、JSON.parseの結果など)に対して使うのは危険で、型と実際のデータが乖離していてもコンパイラが見逃す。
parse(Zodなどのランタイムバリデーション)
実行時にデータの構造を検査する。型に合わなければエラーを投げる。スキーマに定義されていないフィールドはデフォルトで除去される。パースが通った後のデータはTypeScriptの型としても推論されるので、型安全が実行時・コンパイル時の両方で成立する。
外部からのデータに対してはこちらを使うべき。コストは「スキーマ定義を書く手間」と「実行時の処理オーバーヘッド(ほぼ無視できる程度)」。
制御文字のバリデーション
文字列バリデーションでもう一つ見落としがちなのが、制御文字の存在だ。制御文字はASCIIの0x00〜0x1Fと0x7Fに該当する、画面に表示されない特殊な文字群で、タブ(\t)や改行(\n)もこれに含まれる。
APIの入力フィールドに制御文字が混入すると、ログ汚染やインジェクションの原因になる。特にヌル文字(\0)やエスケープシーケンス(\x1B)は危険だ。
Zodでは.regex()や.refine()で制御文字を検出・排除できる。
// 改行・タブ以外の制御文字を弾く
const safeString = z.string().regex(
/^[^\x00-\x08\x0B\x0C\x0E-\x1F\x7F]*$/,
'制御文字は使用できません'
);
フィールドの用途に応じて許可範囲を変える。テキストエリアなら改行を許可し、ユーザー名なら一切の制御文字を弾く、という設計が一般的だ。
実務でのスキーマ設計に活かす
ここまでの知識をもとに、APIエンドポイントのバリデーションスキーマを組む例を示す。
const createUserSchema = z.object({
// 必須・空文字不可・制御文字不可
name: z.string()
.min(1, '名前は必須です')
.max(100)
.regex(/^[^\x00-\x1F\x7F]*$/, '不正な文字が含まれています'),
// 必須・メール形式
email: z.string()
.min(1)
.email('メールアドレスの形式が正しくありません'),
// 任意・nullも許容
bio: z.string().max(500).nullish(),
// 必須・正の整数
age: z.number().int().positive(),
// 必須・定義値のみ
role: z.enum(['admin', 'member', 'guest']),
// 任意・空配列も許容
tags: z.array(z.string().min(1)).optional(),
});
各フィールドで何を許容し何を弾くかが明示されている。このスキーマを見るだけで、APIの入力仕様がドキュメントとして機能する。Zodのスキーマは「実行可能な仕様書」だ。迷ったらsafeParseで試し、テストに残しておく。その積み重ねが、堅牢なAPI設計につながる。
Zodのpreprocessは、バリデーション前に入力値を変換する仕組み。
z.preprocess(変換関数, スキーマ)の形で使う。
// 文字列で来た数値を数値型に変換してからバリデーション
const schema = z.preprocess(
(val) => (typeof val === "string" ? Number(val) : val),
z.number().min(0)
);
schema.parse("42"); // => 42
schema.parse("abc"); // => NaN → z.number()で弾かれる
schema.parse(-1); // => z.number().min(0)で弾かれる典型的なユースケースとしては、フォーム入力やクエリパラメータのように「全部stringで来る」場面で型変換を挟むのが多い。
// 空文字をundefinedに変換(optionalフィールド向け)
const optionalString = z.preprocess(
(val) => (val === "" ? undefined : val),
z.string().optional()
);
// カンマ区切り文字列を配列に
const csvToArray = z.preprocess(
(val) => (typeof val === "string" ? val.split(",").map(s => s.trim()) : val),
z.array(z.string())
);
// trimしてから検証
const trimmed = z.preprocess(
(val) => (typeof val === "string" ? val.trim() : val),
z.string().min(1) // trim後に空文字ならエラー
);注意点をいくつか。
transformとの違い — preprocessはバリデーション前に動く。transformはバリデーション後に動く。なのでpreprocessの変換関数の引数はunknown型になる。型安全ではないので、変換関数内で型チェックを自分で書く必要がある。
coerceとの使い分け — Zod 3.20+でz.coerce.number()のようなcoerce系が追加された。単純な型変換(string→number等)ならcoerceのほうが簡潔。preprocessは「空文字→undefined」「trim」「カンマ分割」みたいなカスタム変換が必要なときに使う。
// coerceで十分なケース
z.coerce.number() // 内部的にNumber(val)してくれる
z.coerce.boolean()
z.coerce.date()
// preprocessが必要なケース(独自ロジックが要る)
z.preprocess(val => val === "" ? undefined : val, z.string().optional())エラーハンドリング — preprocessの変換関数が例外を投げると、そのままthrowされる(Zodのエラーにはならない)。変換関数内では例外を投げないようにして、不正値はそのままスキーマ側に流してバリデーションエラーとして拾わせるのが定石。