Skip to content

Zod スキーマ: オプショナルフィールドと最小文字列長制約の実装方法

問題の説明

フォーム開発において、特定のフィールドをオプショナル(未入力可)にしつつ、入力がある場合には最小文字数制約(例: 4文字以上)を適用したいという要件はよくあります。Zod スキーマでこのようなバリデーションを実装しようとすると、以下のような課題に直面します:

  • z.optional() のみを使用すると、空文字 "" が許可されない
  • min() 制約単体では、未入力(空文字)の場合にバリデーションエラーが発生する
  • ユーザビリティを考慮し、空文字は undefined として扱いたい
typescript
// 問題のある実装例
z.optional(z.string().min(4)); // 空文字が許容されない

この記事では、Zod で「オプショナル OR 最小長制約」を正しく実装する方法を解説します。

推奨解決策

基本アプローチ: optional() と literal() の組み合わせ

最もシンプルで効果的な方法は、optional()literal('') を組み合わせる方法です:

typescript
import { z } from "zod";

export const SocialsSchema = z.object({
  myField: z
    .string()
    .min(4, "4文字以上で入力してください")
    .optional()
    .or(z.literal(''))
    .transform(value => value === "" ? undefined : value)
});
typescript
// 成功ケース
SocialsSchema.parse({}); // myField: undefined
SocialsSchema.parse({ myField: "" }); // myField: undefined
SocialsSchema.parse({ myField: "abcd" }); // myField: "abcd"

// 失敗ケース
SocialsSchema.parse({ myField: "abc" }); // エラー(3文字)
SocialsSchema.parse({ myField: 123 }); // エラー(数値型)

動作説明:

  1. optional(): undefined を許可
  2. literal(''): 空文字を明示的に許可
  3. transform(): 空文字をundefinedに変換

ユーザビリティ向上のポイント

transform() で空文字をundefinedに変換することで、データ処理の一貫性が向上します。フォーム送信時には空文字ではなく完全に未定義として扱われます。

ユーティリティ関数による拡張(再利用可能)

複数箇所で同じバリデーションが必要な場合、再利用可能な関数として抽象化できます:

typescript
import { z } from "zod";

export function optionalStringWithMin(min: number, message?: string) {
  return z
    .string()
    .min(min, message || `${min}文字以上で入力してください`)
    .optional()
    .or(z.literal(''))
    .transform(val => val === "" ? undefined : val);
}
typescript
import { optionalStringWithMin } from './utils/zodHelpers';

const UserSchema = z.object({
  username: optionalStringWithMin(4),
  bio: optionalStringWithMin(10, "10文字以上で入力してください")
});

代替アプローチと比較

union() を使った方法(エラーメッセージ制御)

typescript
const myField = z
  .union([
    z.string().min(4), // 優先エラーメッセージ
    z.string().length(0)
  ])
  .optional()
  .transform(e => e === "" ? undefined : e);
typescript
// 順序が逆だと不正確なエラーメッセージになる
z.union([
  z.string().length(0), // こちらのエラーが優先される
  z.string().min(4)     // エラー時はこのメッセージが表示されない
])

重要な注意点

  • union() 内の順序がエラーメッセージに影響します
  • 基本的には最初のスキーマのエラーが表示されます
  • 最小制約のメッセージを優先するには min() を先に記述する必要があります

nullish() の誤解と限界

一部の回答で提案される nullish() は、空文字を扱えません

typescript
// 空文字を許可しない実装(不適切)
z.string().min(4).nullish();

// 許容される値
undefined, null, "abcd"

// 許容されない値
"" (空文字) // エラー発生!

よくある間違い

nullish()undefinednull のみを扱い、空文字は完全に別の値として扱われます。要件が「未入力または最小長」の場合は適切な選択肢ではありません。

各メソッドの動作比較表

メソッドundefinednull"" (空文字)"abc" (短い文字)"abcd"
optional()
nullable()
nullish()
推奨パターン✅ → undefined

ベストプラクティスのまとめ

  1. 空文字をundefinedに変換
    transform() を活用してデータの一貫性を保つ

    typescript
    .transform(value => value === "" ? undefined : value)
  2. エラーメッセージを明確に
    ユーザーが修正しやすい具体的なメッセージを指定

    typescript
    .min(4, "4文字以上必要です")
  3. 大規模プロジェクトではユーティリティ化
    同じバリデーションロジックが複数箇所で必要な場合は関数化

  4. ユニオンの順序に注意
    union() を使用する場合はエラーメッセージの優先度を考慮する

  5. テストの重要性
    全パターンを網羅したテストを実装

    typescript
    // テスト例(Jest使用)
    test('空文字をundefinedに変換', () => {
      expect(SocialsSchema.parse({ myField: "" }))
        .toEqual({ myField: undefined });
    });

最終的な実装の選択

プロジェクトの規模と要件に応じて:

  • 単発のスキーマ → 基本アプローチ
  • 複数のスキーマで再利用 → ユーティリティ関数

この実装により、「入力がない」または「4文字以上の入力がある」という柔軟なバリデーションをZodで効率的に実現できます。