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

API開発でZodを使ったバリデーションを実装していると、ふと悩んでしまう

z.string()って空文字通すんだっけ?」「nullとundefinedってどっちがデフォルトで弾かれるんだっけ?」—

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) で弾く

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()はこの出力を次のスキーマの入力として渡す仕組みです。

transformなし(型が変わらない) 入力: “hello” z.string().email() 出力: “hello” → 入力も出力もstring。pipeは不要 transform + pipe(型が変わる) 入力: “42” string transform(Number) string → number .pipe(z.number().min(1)) numberとして検証 出力: 42 number → transformで型が変わり、pipeで変換後の値を検証 pipeがないと、transform後にnumber用のバリデーション(.min()等)を 「numberのスキーマとして」適用する手段がない

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() が素直です。

parse() と safeParse() の挙動 入力データ schema.xxx(input) parse() 成功 失敗 データを直接返す const data = { name, age } 例外を throw throw new ZodError try/catch が必要 safeParse() 成功 失敗 オブジェクトを返す { success: true, data: {…} } オブジェクトを返す { success: false, error: ZodError } try/catch 不要 共通点 失敗時の中身はどちらも同じ ZodError (issues / path / message などを持つ) 違い 制御フロー(throw するか、return するか)

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のエラーにはならない)。変換関数内では例外を投げないようにして、不正値はそのままスキーマ側に流してバリデーションエラーとして拾わせるのが定石。

目次