Skip to content

Commit

Permalink
improve json schema support
Browse files Browse the repository at this point in the history
  • Loading branch information
edmundhung committed Aug 17, 2024
1 parent 3b8048e commit 3c043ee
Show file tree
Hide file tree
Showing 3 changed files with 134 additions and 27 deletions.
42 changes: 26 additions & 16 deletions packages/conform-zod/coercion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export function ifNonEmptyString(fn: (text: string) => unknown) {
export function enableTypeCoercion<Schema extends ZodTypeAny>(
type: Schema,
cache = new Map<ZodTypeAny, ZodTypeAny>(),
parentType: ZodTypeAny | null = null,
): ZodType<output<Schema>> {
const result = cache.get(type);

Expand Down Expand Up @@ -153,6 +154,11 @@ export function enableTypeCoercion<Schema extends ZodTypeAny>(
} 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;
Expand All @@ -171,15 +177,15 @@ export function enableTypeCoercion<Schema extends ZodTypeAny>(
.pipe(
new ZodArray({
...def,
type: enableTypeCoercion(def.type, cache),
type: enableTypeCoercion(def.type, cache, type),
}),
);
} else if (def.typeName === 'ZodObject') {
const shape = Object.fromEntries(
Object.entries(def.shape()).map(([key, def]) => [
key,
// @ts-expect-error see message above
enableTypeCoercion(def, cache),
enableTypeCoercion(def, cache, type),
]),
);
schema = new ZodObject({
Expand All @@ -194,7 +200,7 @@ export function enableTypeCoercion<Schema extends ZodTypeAny>(
} else {
schema = new ZodEffects({
...def,
schema: enableTypeCoercion(def.schema, cache),
schema: enableTypeCoercion(def.schema, cache, type),
});
}
} else if (def.typeName === 'ZodOptional') {
Expand All @@ -203,7 +209,7 @@ export function enableTypeCoercion<Schema extends ZodTypeAny>(
.pipe(
new ZodOptional({
...def,
innerType: enableTypeCoercion(def.innerType, cache),
innerType: enableTypeCoercion(def.innerType, cache, type),
}),
);
} else if (def.typeName === 'ZodDefault') {
Expand All @@ -212,61 +218,65 @@ export function enableTypeCoercion<Schema extends ZodTypeAny>(
.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<any>,
enableTypeCoercion(
option,
cache,
type,
) as ZodDiscriminatedUnionOption<any>,
]),
),
});
} else if (def.typeName === 'ZodTuple') {
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) {
Expand Down
33 changes: 22 additions & 11 deletions playground/app/routes/parse-with-zod.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof literalSchema>;
type Json = Literal | { [key: string]: Json } | Json[];
const jsonSchema: z.ZodType<Json> = 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) {
Expand Down
86 changes: 86 additions & 0 deletions tests/conform-zod.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof literalSchema>;
type Json = Literal | { [key: string]: Json } | Json[];
const jsonSchema: z.ZodType<Json> = 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', () => {
Expand Down

0 comments on commit 3c043ee

Please sign in to comment.