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

Zodは空文字やnullを通すのか — デフォルト挙動の早見表

約11分
この章の目次開く

Zodを書いていると、必ずこの手の疑問に突き当たります。

  • z.string() って、空文字 "" は通るんだっけ?
  • null と undefined、デフォルトで弾かれるのはどっちだっけ?
  • .optional() と .nullable()、どっちがどっちだっけ?

この章はその都度開く前提の早見表です。表を見て解決したらそのまま閉じてもらって構いませんし、理由まで知りたければ表の下の解説を読んでください。

学習者学習者

z.string().min(1) を付けたのに空白だけの入力が通っちゃった…これ、仕様なの?


早見表1: そのまま通るか

素のスキーマに値を渡したときに、検証を通過するかどうかです(z.array() は z.array(z.string()) で確認しています)。

入力値z.string()z.number()z.boolean()z.array()
""通る✗✗✗
" "(空白のみ)通る✗✗✗
"123"通る✗✗✗
0✗通る✗✗
NaN✗✗✗✗
Infinity✗✗✗✗
true✗✗通る✗
[](空配列)✗✗✗通る
null✗✗✗✗
undefined✗✗✗✗

この表から押さえておきたいのは2点です。

空文字と空配列は「空だから」では弾かれない

z.string() は「文字列であるか」だけを見ます。長さ0の文字列も立派な文字列なので通ります。同じ理由で z.array() は空配列を通します。「必須項目なのに空で登録できてしまった」というバグの多くはこれが原因です。空を弾きたいなら、後述の早見表3のように明示的に書きます。

0 は通り、NaN は弾かれる

z.number() は 0 を通します。0 は正当な数値だからです。一方 NaN は、JavaScriptの typeof 上は "number" ですが、Zodは弾きます。Infinity も弾かれます。

日付は別枠で考える

z.date() は日付文字列を受け取らない

z.date() が通すのは Date オブジェクトだけです。"2026-01-01" のような文字列は弾かれます。JSONにDate型は存在しないので、APIレスポンスを検証するときに z.date() を書くとほぼ確実に落ちます。

やりたいこと書き方
Date オブジェクトを検証するz.date()
"2026-01-01" 形式の文字列を検証するz.iso.date()
"2026-01-01T00:00:00Z" 形式を検証するz.iso.datetime()
文字列を受け取って Date に変換するz.coerce.date()

z.iso.date() はハイフン区切りのISO形式だけを通します。"2026/01/01" はスラッシュ区切りなので弾かれます。


早見表2: nullとundefinedの許し方

いちばん忘れやすいところです。まず前提として、null も undefined も、デフォルトでは両方とも弾かれます。「どちらかが特別扱いで通る」ということはありません。

違いが出るのは、許すときの書き方です。

書き方null を渡すundefined を渡すキー自体が無い
そのまま✗✗✗
.optional()✗通る通る
.nullable()通る✗✗
.nullish()通る通る通る
.default("D")✗"D" になる"D" になる
.catch("C")"C" になる"C" になる"C" になる

覚え方はそのままで、optional = undefined を許す、nullable = null を許す、nullish = 両方です。

先生先生

.optional() を付けたのに null が来て落ちる、というのが実務で一番多い事故。APIやDBは「値が無い」を null で表すことが多いから、フロントの感覚で .optional() だけ付けると噛み合わないんだ。

「キーが無い」は undefined と同じ扱い

オブジェクトのキーそのものが存在しない場合、Zodはそれを undefined が渡されたのと同じように扱います。実際、必須キーが欠けているときのエラーは次のようになります。

z.object({ a: z.string() }).safeParse({});
// issues[0].message: "Invalid input: expected string, received undefined"
ts

{} にはキー a すらありませんが、メッセージは received undefined です。だからキーの省略を許したいときは .nullable() ではなく .optional() が正解になります。

.default() と .catch() は別物

どちらも「値が無いときに既定値を入れる」ように見えますが、動く条件がまったく違います。

既定値が入る条件
.default(v)入力が undefined のときだけ
.catch(v)検証に失敗したとき全部(型が違う、範囲外、null、なんでも)
z.string().default('D').safeParse(null); // ✗ 失敗する
z.string().catch('C').safeParse(null); // 成功して 'C'
z.string().catch('C').safeParse(12345); // 成功して 'C'
ts

.default() には関数も渡せます。呼ばれるたびに評価されるので、現在時刻やランダム値の既定値に使えます。

z.date().default(() => new Date());
ts

早見表3: 「空」を弾きたいときの定型

弾きたいもの書き方補足
空文字 ""z.string().min(1)空白だけの " " は通ってしまう
空白だけの入力z.string().trim().min(1)trim してから長さを見るので両方弾ける
空配列 []z.array(z.string()).min(1)「1件以上必須」の意味になる
null / undefined何も付けないデフォルトで弾かれる

実行結果で並べると、.min(1) だけでは足りないことがはっきりします。

スキーマ""" "
z.string()通る通る
z.string().min(1)✗通る
z.string().trim().min(1)✗✗
フォームの必須入力には .trim().min(1) を使う、と覚えておくと事故が減ります。
データを確認している人のイラスト

よくあるハマりどころ

.optional() を付けたのに null で落ちる

APIやDBは「値なし」を null で返すことが多く、.optional() では受けられません。外部から来るデータで「値が無いこともある」項目は、.nullish() にしておくと両方受けられます。どちらが来るか決まっているなら、決まっている方だけを許す方が厳密です。

必須のはずのフォーム項目が空文字で登録できる

早見表1のとおり z.string() は "" を通します。HTMLのフォームは未入力の項目を空文字として送るため、未入力が「空文字という値」としてそのまま通過します。.trim().min(1) で塞ぎます。

数値の項目が 0 を弾いてしまう

逆のパターンです。「未入力なら弾く」つもりで .min(1) を書くと、正当な入力である 0 まで弾かれます。数量や個数のように 0 が意味を持つ項目では .min(0) と書くか、そもそも下限を指定しません。

z.coerce.number() を使ったら空欄が 0 になった

z.coerce.number() は変換してから検証するため、空文字も null もエラーにならず 0 になります(Number("") が 0 になるのと同じ理屈です)。フォームの空欄が0で保存される事故につながるので、変換を挟むときは「変換後の値」で早見表を引き直してください。

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

  • z.string() は空文字も空白のみの文字列も通す。必須入力は .trim().min(1)
  • null と undefined は両方ともデフォルトで弾かれる。違うのは許し方
  • optional = undefined、nullable = null、nullish = 両方
  • オブジェクトのキー欠落は undefined 扱いなので .optional() の担当
  • .default() は undefined のときだけ、.catch() は失敗全部を握りつぶす
  • z.date() は文字列を通さない。JSON由来の日付は z.iso.date() か z.coerce.date()

次の章では、ここまで単体で見てきたスキーマを組み合わせる z.objectでオブジェクトを検証する に進みます。定義していないキーが来たときにZodが黙って捨てている、という挙動もそこで扱います。

参考リンク

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