diff --git a/.changeset/perfect-peaches-design.md b/.changeset/perfect-peaches-design.md new file mode 100644 index 0000000..78eb976 --- /dev/null +++ b/.changeset/perfect-peaches-design.md @@ -0,0 +1,5 @@ +--- +"@stevent-team/react-zoom-form": minor +--- + +Support for intersections and unions in the Zod schema diff --git a/examples/App.tsx b/examples/App.tsx index 4760c3f..e965eaa 100644 --- a/examples/App.tsx +++ b/examples/App.tsx @@ -8,6 +8,7 @@ import Nested from './nested' import Coerced from './coerced' import Conditional from './conditional' import Controlled from './controlled' +import Intersection from './intersection' import KitchenSink from './kitchen-sink' interface Example { @@ -47,6 +48,11 @@ const EXAMPLES: Example[] = [ path: '/controlled', component: Controlled, }, + { + name: 'Intersection Schema', + path: '/intersection', + component: Intersection, + }, { name: 'Kitchen Sink', path: '/kitchen-sink', diff --git a/examples/intersection/index.tsx b/examples/intersection/index.tsx new file mode 100644 index 0000000..a5a5088 --- /dev/null +++ b/examples/intersection/index.tsx @@ -0,0 +1,54 @@ +import { Errors, SubmitHandler, getValue, useForm } from '@stevent-team/react-zoom-form' +import { z } from 'zod' +import Output from '../Output' + +export const schema = z.intersection( + z.object({ + common: z.string(), + }), + z.discriminatedUnion('size', [ + z.object({ + size: z.literal('small'), + smallProperty: z.string(), + }), + z.object({ + size: z.literal('large'), + largeProperty: z.string(), + }), + ]), +) + +const Intersection = ({ onSubmit }: { onSubmit: SubmitHandler }) => { + const { fields, handleSubmit, isDirty } = useForm({ schema, initialValues: { size: 'small' } }) + + return <> +
+ + + + + + + + + {getValue(fields.size) === 'small' ? <> + + + + : <> + + + + } + + + + + + +} + +export default Intersection diff --git a/lib/utils.ts b/lib/utils.ts index 70e99e1..72f7478 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -4,12 +4,19 @@ import { z } from 'zod' // A Zod object that can hold nested data type AnyZodContainer = z.AnyZodObject | z.AnyZodTuple | z.ZodArray | z.ZodRecord | z.ZodMap | z.ZodSet +// Intersect everything inside an array after running each through the FieldChain type +type FieldChainEach = ArraySchema extends [infer First extends z.ZodType, ...infer Rest extends z.ZodType[]] + ? FieldChain & FieldChainEach + : unknown + // Creates the type for the field chain by recusively travelling through the Zod schema type RecursiveFieldChain = z.infer extends LeafValue ? z.infer : Schema extends z.AnyZodTuple ? { [K in keyof z.infer]: FieldChain } : Schema extends z.ZodArray ? { [k: number]: FieldChain } : Schema extends z.AnyZodObject ? { [K in keyof z.infer]: FieldChain } + : Schema extends z.ZodIntersection ? FieldChain & FieldChain + : Schema extends (z.ZodUnion | z.ZodDiscriminatedUnion) ? FieldChainEach : Schema extends (z.ZodDefault | z.ZodOptional | z.ZodNullable) ? FieldChain : Schema extends z.ZodEffects ? FieldChain : Schema extends z.ZodLazy ? FieldChain> @@ -54,11 +61,25 @@ export type PartialObject = T extends any[] ? T : Partial /** Excludes undefined from a type, but keeps null */ export type NonUndefined = T extends undefined ? never : T +const getZodObjectShape = (type: z.ZodType) => { + const unwrapped = unwrapZodType(type) + if (unwrapped instanceof z.ZodObject) return unwrapped.shape + return {} +} + export const unwrapZodType = (type: z.ZodType): z.ZodType => { if (type instanceof z.ZodObject || type instanceof z.ZodArray) return type if (type instanceof z.ZodEffects) return unwrapZodType(type.innerType()) + if ((type instanceof z.ZodDiscriminatedUnion || type instanceof z.ZodUnion) && Array.isArray(type.options)) { + return z.ZodObject.create(type.options.reduce((a, o) => ({ ...a, ...getZodObjectShape(o) }), {})) + } + + if (type instanceof z.ZodIntersection) { + return z.ZodObject.create({ ...getZodObjectShape(type._def.left), ...getZodObjectShape(type._def.right) }) + } + const anyType = type as any if (anyType._def?.innerType) return unwrapZodType(anyType._def.innerType) @@ -112,17 +133,16 @@ export const fieldChain = ( return fieldChain(unwrapped._def.type, [...path, { key: Number(key), type: 'array' }], register, fieldRefs, controls) } - // If the current Zod schema is not an array or object, we must be at a leaf node - if (!(unwrapped instanceof z.ZodObject)) { - // Leaf node functions - if (key === 'register') return (options: RegisterOptions = {}) => register(path, schema, controls.setFormValue, fieldRefs, options) - if (key === 'name') return () => path.map(p => p.key).join('.') - - // Attempted to access a property that didn't exist - throw new Error(`Expected ZodObject at "${path.map(p => p.key).join('.')}" got ${schema.constructor.name}`) + if (unwrapped instanceof z.ZodObject) { + return fieldChain(unwrapped.shape[key], [...path, { key, type: 'object' }], register, fieldRefs, controls) } - return fieldChain(unwrapped.shape[key], [...path, { key, type: 'object' }], register, fieldRefs, controls) + // Leaf node functions + if (key === 'register') return (options: RegisterOptions = {}) => register(path, schema, controls.setFormValue, fieldRefs, options) + if (key === 'name') return () => path.map(p => p.key).join('.') + + // Attempted to access a property that didn't exist + throw new Error(`Unsupported type at "${path.map(p => p.key).join('.')}" got ${schema.constructor.name}`) } }) as unknown // Never let them know your next move...