Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(conform-zod): empty string default value support #741

Merged
merged 3 commits into from
Sep 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/empty-guests-hear.md
Original file line number Diff line number Diff line change
@@ -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
16 changes: 10 additions & 6 deletions docs/api/zod/parseWithZod.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
edmundhung marked this conversation as resolved.
Show resolved Hide resolved
baz: z
.string()
.optional()
.transform((value) => value ?? null), // string | null
edmundhung marked this conversation as resolved.
Show resolved Hide resolved
});
```
16 changes: 10 additions & 6 deletions docs/ja/api/zod/parseWithZod.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
```
23 changes: 21 additions & 2 deletions packages/conform-zod/coercion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -38,6 +44,10 @@ export function coerceString(
return undefined;
}

if (value === EMPTY_STRING) {
return '';
}

if (typeof transform !== 'function') {
return value;
}
Expand Down Expand Up @@ -81,15 +91,15 @@ export function isFileSchema(schema: ZodEffects<any, any, any>): 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);
}

/**
* 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<Schema extends ZodTypeAny>(
type: Schema,
Expand Down Expand Up @@ -212,6 +222,15 @@ export function enableTypeCoercion<Schema extends ZodTypeAny>(
.pipe(
new ZodDefault({
...def,
defaultValue: () => {
const value = def.defaultValue();

if (value === '') {
return EMPTY_STRING;
}

return value;
},
innerType: enableTypeCoercion(def.innerType, cache),
}),
);
Expand Down
32 changes: 32 additions & 0 deletions tests/conform-zod.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([], '');

Expand Down Expand Up @@ -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),
});
Expand Down
Loading