Zodの型別デフォルト挙動を整理する——null・undefined・空文字はどう扱われるのか

API開発でZodを使ったバリデーションを実装していると、ふと手が止まる瞬間がある。「z.string()って空文字通すんだっけ?」「nullとundefinedってどっちがデフォルトで弾かれるんだっけ?」——こういう細かい挙動は、毎回ドキュメントを確認するか、safeParseで試すかのどちらかになりがちだ。

この記事では、Zodの主要な型ごとにデフォルトで何を許容し、何を弾くのかを整理する。一度頭に入れておけば、スキーマ設計で迷う時間がかなり減るはずだ。

目次

そもそもnull・undefined・空文字は何が違うのか

バリデーションの話に入る前に、この3つの違いを明確にしておく。JavaScriptに慣れていても、意外と曖昧なまま使っている人は多い。

  • null は「値が存在しないこと」を明示的に表す。開発者が意図的に「空」を設定した状態。
  • undefined は「値がまだ定義されていない」状態。オブジェクトに存在しないプロパティにアクセスしたときや、関数の引数を省略したときに現れる。
  • ""(空文字) は立派なstring型の値。長さが0なだけで、型としては文字列。

この区別がバリデーションで重要になる理由は明快で、APIのリクエストボディでは3つすべてが飛んでくる可能性があるからだ。フォームの未入力フィールドは空文字になることが多いし、オプショナルなフィールドはそもそもキーごと省略されてundefinedになる。nullはフロントエンドのステート管理から紛れ込むことがある。

以下の図で、3つの関係を整理しておく。

undefined 値が未定義 キーが存在しない null 明示的に「空」 意図的な不在 “” (空文字) string型の値 長さが0なだけ typeof → “undefined” typeof → “object” typeof → “string” 3つともif文では falsy だが、型もセマンティクスも異なる

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']

以下の図で、型ごとの「許容される境界値」を一覧にする。

型別:デフォルトで許容される境界値 string → “” (空文字) が通る .min(1) で弾く number → 0 が通る / NaN は弾く .positive() で弾く boolean → false が通る .refine() で弾く enum → 定義外はすべて弾く 最も厳格 array → [] (空配列) が通る .min(1) で弾く

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設計につながる。

目次