Zod スキーマ: オプショナルフィールドと最小文字列長制約の実装方法
問題の説明
フォーム開発において、特定のフィールドをオプショナル(未入力可)にしつつ、入力がある場合には最小文字数制約(例: 4文字以上)を適用したいという要件はよくあります。Zod スキーマでこのようなバリデーションを実装しようとすると、以下のような課題に直面します:
z.optional()
のみを使用すると、空文字""
が許可されないmin()
制約単体では、未入力(空文字)の場合にバリデーションエラーが発生する- ユーザビリティを考慮し、空文字は
undefined
として扱いたい
// 問題のある実装例
z.optional(z.string().min(4)); // 空文字が許容されない
この記事では、Zod で「オプショナル OR 最小長制約」を正しく実装する方法を解説します。
推奨解決策
基本アプローチ: optional() と literal() の組み合わせ
最もシンプルで効果的な方法は、optional()
と literal('')
を組み合わせる方法です:
import { z } from "zod";
export const SocialsSchema = z.object({
myField: z
.string()
.min(4, "4文字以上で入力してください")
.optional()
.or(z.literal(''))
.transform(value => value === "" ? undefined : value)
});
// 成功ケース
SocialsSchema.parse({}); // myField: undefined
SocialsSchema.parse({ myField: "" }); // myField: undefined
SocialsSchema.parse({ myField: "abcd" }); // myField: "abcd"
// 失敗ケース
SocialsSchema.parse({ myField: "abc" }); // エラー(3文字)
SocialsSchema.parse({ myField: 123 }); // エラー(数値型)
動作説明:
optional()
:undefined
を許可literal('')
: 空文字を明示的に許可transform()
: 空文字をundefined
に変換
ユーザビリティ向上のポイント
transform()
で空文字をundefined
に変換することで、データ処理の一貫性が向上します。フォーム送信時には空文字ではなく完全に未定義として扱われます。
ユーティリティ関数による拡張(再利用可能)
複数箇所で同じバリデーションが必要な場合、再利用可能な関数として抽象化できます:
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);
}
import { optionalStringWithMin } from './utils/zodHelpers';
const UserSchema = z.object({
username: optionalStringWithMin(4),
bio: optionalStringWithMin(10, "10文字以上で入力してください")
});
代替アプローチと比較
union() を使った方法(エラーメッセージ制御)
const myField = z
.union([
z.string().min(4), // 優先エラーメッセージ
z.string().length(0)
])
.optional()
.transform(e => e === "" ? undefined : e);
// 順序が逆だと不正確なエラーメッセージになる
z.union([
z.string().length(0), // こちらのエラーが優先される
z.string().min(4) // エラー時はこのメッセージが表示されない
])
重要な注意点
union()
内の順序がエラーメッセージに影響します- 基本的には最初のスキーマのエラーが表示されます
- 最小制約のメッセージを優先するには
min()
を先に記述する必要があります
nullish() の誤解と限界
一部の回答で提案される nullish()
は、空文字を扱えません:
// 空文字を許可しない実装(不適切)
z.string().min(4).nullish();
// 許容される値
undefined, null, "abcd"
// 許容されない値
"" (空文字) // エラー発生!
よくある間違い
nullish()
は undefined
と null
のみを扱い、空文字は完全に別の値として扱われます。要件が「未入力または最小長」の場合は適切な選択肢ではありません。
各メソッドの動作比較表
メソッド | undefined | null | "" (空文字) | "abc" (短い文字) | "abcd" |
---|---|---|---|---|---|
optional() | ✅ | ❌ | ❌ | ❌ | ✅ |
nullable() | ❌ | ✅ | ❌ | ❌ | ✅ |
nullish() | ✅ | ✅ | ❌ | ❌ | ✅ |
推奨パターン | ✅ | ❌ | ✅ → undefined | ❌ | ✅ |
ベストプラクティスのまとめ
空文字を
undefined
に変換transform()
を活用してデータの一貫性を保つtypescript.transform(value => value === "" ? undefined : value)
エラーメッセージを明確に
ユーザーが修正しやすい具体的なメッセージを指定typescript.min(4, "4文字以上必要です")
大規模プロジェクトではユーティリティ化
同じバリデーションロジックが複数箇所で必要な場合は関数化ユニオンの順序に注意
union()
を使用する場合はエラーメッセージの優先度を考慮するテストの重要性
全パターンを網羅したテストを実装typescript// テスト例(Jest使用) test('空文字をundefinedに変換', () => { expect(SocialsSchema.parse({ myField: "" })) .toEqual({ myField: undefined }); });
最終的な実装の選択
プロジェクトの規模と要件に応じて:
- 単発のスキーマ → 基本アプローチ
- 複数のスキーマで再利用 → ユーティリティ関数
この実装により、「入力がない」または「4文字以上の入力がある」という柔軟なバリデーションをZodで効率的に実現できます。