API開発でZodを使ったバリデーションを実装していると、ふと手が止まる瞬間がある。「z.string()って空文字通すんだっけ?」「nullとundefinedってどっちがデフォルトで弾かれるんだっけ?」——こういう細かい挙動は、毎回ドキュメントを確認するか、safeParseで試すかのどちらかになりがちだ。
この記事では、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']
以下の図で、型ごとの「許容される境界値」を一覧にする。
safeParse で挙動を確認する
スキーマの挙動に自信がないときは、safeParseを使って確認するのが確実だ。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);
});
});
制御文字のバリデーション
文字列バリデーションでもう一つ見落としがちなのが、制御文字の存在だ。制御文字は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設計につながる。