diff --git a/.changeset/empty-guests-hear.md b/.changeset/empty-guests-hear.md new file mode 100644 index 00000000..d92b2dea --- /dev/null +++ b/.changeset/empty-guests-hear.md @@ -0,0 +1,9 @@ +--- +'@conform-to/zod': patch +--- + +fix(conform-zod): empty string default value support + +Previously, we suggested using `.default()` to set a fallback value. However, `.default()` does not work as expected with `z.string().default('')`. This issue has now been resolved, but keep in mind that the default value is still subject to validation errors. For more predictable results, we recommend using `.transform(value => value ?? defaultValue)` instead. + +Fix #676 diff --git a/docs/api/zod/parseWithZod.md b/docs/api/zod/parseWithZod.md index fa99d429..999622a4 100644 --- a/docs/api/zod/parseWithZod.md +++ b/docs/api/zod/parseWithZod.md @@ -86,14 +86,18 @@ const schema = z.object({ ### Default values -Conform already preprocesses empty values to `undefined`. Add `.default()` to your schema to define a default value that will be returned instead. - -Zod will return the default value if the input is `undefined` after preprocessing. This also has the effect of changing the schema return type. +Conform will always strip empty values to `undefined`. If you need a default value, please use `.transform()` to define a fallback value that will be returned instead. ```tsx const schema = z.object({ - foo: z.string(), // string | undefined - bar: z.string().default('bar'), // string - baz: z.string().nullable().default(null), // string | null + foo: z.string().optional(), // string | undefined + bar: z + .string() + .optional() + .transform((value) => value ?? ''), // string + baz: z + .string() + .optional() + .transform((value) => value ?? null), // string | null }); ``` diff --git a/docs/ja/api/zod/parseWithZod.md b/docs/ja/api/zod/parseWithZod.md index 6ec6de4d..fe63fd89 100644 --- a/docs/ja/api/zod/parseWithZod.md +++ b/docs/ja/api/zod/parseWithZod.md @@ -86,14 +86,18 @@ const schema = z.object({ ### デフォルト値 -Conform はすでに空の値を `undefined` に前処理しています。 `.default()` をスキーマに追加して、代わりに返されるデフォルト値を定義します。 - -Zod は、前処理後の入力が `undefined` の場合、デフォルト値を返します。これはスキーマの戻り値の型を変更する効果もあります。 +Conform は常に空の文字列を削除し、それらを「undefined」にします。 `.transform()` をスキーマに追加して、代わりに返されるデフォルト値を定義します。 ```tsx const schema = z.object({ - foo: z.string(), // string | undefined - bar: z.string().default('bar'), // string - baz: z.string().nullable().default(null), // string | null + foo: z.string().optional(), // string | undefined + bar: z + .string() + .optional() + .transform((value) => value ?? ''), // string + baz: z + .string() + .optional() + .transform((value) => value ?? null), // string | null }); ``` diff --git a/packages/conform-zod/coercion.ts b/packages/conform-zod/coercion.ts index a6a8cd63..e9a22a95 100644 --- a/packages/conform-zod/coercion.ts +++ b/packages/conform-zod/coercion.ts @@ -22,6 +22,12 @@ import type { output, } from 'zod'; +/** + * A special string value to represent empty string + * Used to prevent empty string from being stripped to undefined when using `.default()` + */ +const EMPTY_STRING = '__EMPTY_STRING__'; + /** * Helpers for coercing string value * Modify the value only if it's a string, otherwise return the value as-is @@ -38,6 +44,10 @@ export function coerceString( return undefined; } + if (value === EMPTY_STRING) { + return ''; + } + if (typeof transform !== 'function') { return value; } @@ -81,7 +91,7 @@ export function isFileSchema(schema: ZodEffects): boolean { } /** - * @deprecated Conform coerce empty strings to undefined by default + * @deprecated Conform strip empty string to undefined by default */ export function ifNonEmptyString(fn: (text: string) => unknown) { return (value: unknown) => coerceString(value, fn); @@ -89,7 +99,7 @@ export function ifNonEmptyString(fn: (text: string) => unknown) { /** * Reconstruct the provided schema with additional preprocessing steps - * This coerce empty values to undefined and transform strings to the correct type + * This strips empty values to undefined and coerces string to the correct type */ export function enableTypeCoercion( type: Schema, @@ -212,6 +222,15 @@ export function enableTypeCoercion( .pipe( new ZodDefault({ ...def, + defaultValue: () => { + const value = def.defaultValue(); + + if (value === '') { + return EMPTY_STRING; + } + + return value; + }, innerType: enableTypeCoercion(def.innerType, cache), }), ); diff --git a/tests/conform-zod.spec.ts b/tests/conform-zod.spec.ts index d642f1dc..c6e00334 100644 --- a/tests/conform-zod.spec.ts +++ b/tests/conform-zod.spec.ts @@ -846,6 +846,8 @@ describe('conform-zod', () => { d: z.date().default(defaultDate), e: z.instanceof(File).default(defaultFile), f: z.array(z.string()).default(['foo', 'bar']), + g: z.string().nullable().default(null), + h: z.string().default(''), }); const emptyFile = new File([], ''); @@ -878,6 +880,36 @@ describe('conform-zod', () => { d: defaultDate, e: defaultFile, f: ['foo', 'bar'], + g: null, + h: '', + }, + reply: expect.any(Function), + }); + + const today = new Date(); + const schema2 = z.object({ + a: z.string().email('invalid').default(''), + b: z.number().gt(10, 'invalid').default(0), + c: z + .boolean() + .refine((value) => !!value, 'invalid') + .default(false), + d: z.date().min(today, 'invalid').default(defaultDate), + e: z + .instanceof(File) + .refine((file) => file.size > 100, 'invalid') + .default(defaultFile), + }); + + expect(parseWithZod(createFormData([]), { schema: schema2 })).toEqual({ + status: 'error', + payload: {}, + error: { + a: ['invalid'], + b: ['invalid'], + c: ['invalid'], + d: ['invalid'], + e: ['invalid'], }, reply: expect.any(Function), });