diff --git a/packages/conform-zod/coercion.ts b/packages/conform-zod/coercion.ts index a6a8cd63..9d12f07a 100644 --- a/packages/conform-zod/coercion.ts +++ b/packages/conform-zod/coercion.ts @@ -94,6 +94,7 @@ export function ifNonEmptyString(fn: (text: string) => unknown) { export function enableTypeCoercion( type: Schema, cache = new Map(), + parentType: ZodTypeAny | null = null, ): ZodType> { const result = cache.get(type); @@ -153,6 +154,11 @@ export function enableTypeCoercion( } else if (def.typeName === 'ZodArray') { schema = any() .transform((value) => { + // If the parent type is a union, skip auto coercion + if (parentType?._def.typeName === 'ZodUnion') { + return value; + } + // No preprocess needed if the value is already an array if (Array.isArray(value)) { return value; @@ -171,7 +177,7 @@ export function enableTypeCoercion( .pipe( new ZodArray({ ...def, - type: enableTypeCoercion(def.type, cache), + type: enableTypeCoercion(def.type, cache, type), }), ); } else if (def.typeName === 'ZodObject') { @@ -179,7 +185,7 @@ export function enableTypeCoercion( Object.entries(def.shape()).map(([key, def]) => [ key, // @ts-expect-error see message above - enableTypeCoercion(def, cache), + enableTypeCoercion(def, cache, type), ]), ); schema = new ZodObject({ @@ -194,7 +200,7 @@ export function enableTypeCoercion( } else { schema = new ZodEffects({ ...def, - schema: enableTypeCoercion(def.schema, cache), + schema: enableTypeCoercion(def.schema, cache, type), }); } } else if (def.typeName === 'ZodOptional') { @@ -203,7 +209,7 @@ export function enableTypeCoercion( .pipe( new ZodOptional({ ...def, - innerType: enableTypeCoercion(def.innerType, cache), + innerType: enableTypeCoercion(def.innerType, cache, type), }), ); } else if (def.typeName === 'ZodDefault') { @@ -212,37 +218,41 @@ export function enableTypeCoercion( .pipe( new ZodDefault({ ...def, - innerType: enableTypeCoercion(def.innerType, cache), + innerType: enableTypeCoercion(def.innerType, cache, type), }), ); } else if (def.typeName === 'ZodCatch') { schema = new ZodCatch({ ...def, - innerType: enableTypeCoercion(def.innerType, cache), + innerType: enableTypeCoercion(def.innerType, cache, type), }); } else if (def.typeName === 'ZodIntersection') { schema = new ZodIntersection({ ...def, - left: enableTypeCoercion(def.left, cache), - right: enableTypeCoercion(def.right, cache), + left: enableTypeCoercion(def.left, cache, type), + right: enableTypeCoercion(def.right, cache, type), }); } else if (def.typeName === 'ZodUnion') { schema = new ZodUnion({ ...def, options: def.options.map((option: ZodTypeAny) => - enableTypeCoercion(option, cache), + enableTypeCoercion(option, cache, type), ), }); } else if (def.typeName === 'ZodDiscriminatedUnion') { schema = new ZodDiscriminatedUnion({ ...def, options: def.options.map((option: ZodTypeAny) => - enableTypeCoercion(option, cache), + enableTypeCoercion(option, cache, type), ), optionsMap: new Map( Array.from(def.optionsMap.entries()).map(([discriminator, option]) => [ discriminator, - enableTypeCoercion(option, cache) as ZodDiscriminatedUnionOption, + enableTypeCoercion( + option, + cache, + type, + ) as ZodDiscriminatedUnionOption, ]), ), }); @@ -250,23 +260,23 @@ export function enableTypeCoercion( schema = new ZodTuple({ ...def, items: def.items.map((item: ZodTypeAny) => - enableTypeCoercion(item, cache), + enableTypeCoercion(item, cache, type), ), }); } else if (def.typeName === 'ZodNullable') { schema = new ZodNullable({ ...def, - innerType: enableTypeCoercion(def.innerType, cache), + innerType: enableTypeCoercion(def.innerType, cache, type), }); } else if (def.typeName === 'ZodPipeline') { schema = new ZodPipeline({ ...def, - in: enableTypeCoercion(def.in, cache), - out: enableTypeCoercion(def.out, cache), + in: enableTypeCoercion(def.in, cache, type), + out: enableTypeCoercion(def.out, cache, type), }); } else if (def.typeName === 'ZodLazy') { const inner = def.getter(); - schema = lazy(() => enableTypeCoercion(inner, cache)); + schema = lazy(() => enableTypeCoercion(inner, cache, type)); } if (type !== schema) { diff --git a/playground/app/routes/parse-with-zod.tsx b/playground/app/routes/parse-with-zod.tsx index c67f717e..11d961eb 100644 --- a/playground/app/routes/parse-with-zod.tsx +++ b/playground/app/routes/parse-with-zod.tsx @@ -9,20 +9,31 @@ import { Form, useActionData, useLoaderData } from '@remix-run/react'; import { Playground, Field } from '~/components'; import { z } from 'zod'; +const literalSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]); +type Literal = z.infer; +type Json = Literal | { [key: string]: Json } | Json[]; +const jsonSchema: z.ZodType = z.lazy(() => + z.union([literalSchema, z.array(jsonSchema), z.record(jsonSchema)]), +); + const schema = z.object({ username: z.string().min(3, 'Username is too short'), - profile: z.preprocess( - (json) => { - if (typeof json === 'string' && json !== '') { - return JSON.parse(json); + profile: z + .string({ required_error: 'required' }) + .transform((json, ctx) => { + if (typeof json === 'string') { + try { + return JSON.parse(json); + } catch (error) { + ctx.addIssue({ + code: 'custom', + message: 'invalid', + }); + return z.never; + } } - - return; - }, - z.object({ - age: z.number().int().positive(), - }), - ), + }) + .pipe(jsonSchema), }); export async function loader({ request }: LoaderFunctionArgs) { diff --git a/tests/conform-zod.spec.ts b/tests/conform-zod.spec.ts index d642f1dc..e792b336 100644 --- a/tests/conform-zod.spec.ts +++ b/tests/conform-zod.spec.ts @@ -1103,6 +1103,92 @@ describe('conform-zod', () => { reply: expect.any(Function), }); }); + + test('json schema', () => { + // Copied from https://github.com/colinhacks/zod?tab=readme-ov-file#json-type + const literalSchema = z.union([ + z.string(), + z.number(), + z.boolean(), + z.null(), + ]); + type Literal = z.infer; + type Json = Literal | { [key: string]: Json } | Json[]; + const jsonSchema: z.ZodType = z.lazy(() => + z.union([literalSchema, z.array(jsonSchema), z.record(jsonSchema)]), + ); + const schema = z.object({ + example: z + .string({ required_error: 'required' }) + .transform((json, ctx) => { + if (typeof json === 'string') { + try { + return JSON.parse(json); + } catch (error) { + ctx.addIssue({ + code: 'custom', + message: 'invalid', + }); + return z.never; + } + } + }) + .pipe(jsonSchema), + }); + + expect( + parseWithZod(createFormData([['example', '']]), { schema }), + ).toEqual({ + status: 'error', + payload: { + example: '', + }, + error: { + example: ['required'], + }, + reply: expect.any(Function), + }); + expect( + parseWithZod(createFormData([['example', 'abc']]), { schema }), + ).toEqual({ + status: 'error', + payload: { + example: 'abc', + }, + error: { + example: ['invalid'], + }, + reply: expect.any(Function), + }); + expect( + parseWithZod( + createFormData([ + [ + 'example', + JSON.stringify({ test: 'hello world', number: 123, flag: false }), + ], + ]), + { schema }, + ), + ).toEqual({ + status: 'success', + payload: { + example: JSON.stringify({ + test: 'hello world', + number: 123, + flag: false, + }), + }, + value: { + example: { + test: 'hello world', + number: 123, + flag: false, + }, + }, + reply: expect.any(Function), + }); + }); }); test('parseWithZod with errorMap', () => {