Skip to content

Commit

Permalink
Merge pull request #43 from stevent-team/feat/support-intersections-a…
Browse files Browse the repository at this point in the history
…nd-unions

Support for intersections and unions in the schema
  • Loading branch information
GRA0007 committed Aug 18, 2023
2 parents 2a9c6fa + 72d8a73 commit be35389
Show file tree
Hide file tree
Showing 4 changed files with 94 additions and 9 deletions.
5 changes: 5 additions & 0 deletions .changeset/perfect-peaches-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@stevent-team/react-zoom-form": minor
---

Support for intersections and unions in the Zod schema
6 changes: 6 additions & 0 deletions examples/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -47,6 +48,11 @@ const EXAMPLES: Example[] = [
path: '/controlled',
component: Controlled,
},
{
name: 'Intersection Schema',
path: '/intersection',
component: Intersection,
},
{
name: 'Kitchen Sink',
path: '/kitchen-sink',
Expand Down
54 changes: 54 additions & 0 deletions examples/intersection/index.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof schema> }) => {
const { fields, handleSubmit, isDirty } = useForm({ schema, initialValues: { size: 'small' } })

return <>
<form onSubmit={handleSubmit(onSubmit)}>
<label htmlFor={fields.common.name()}>Common field</label>
<input {...fields.common.register()} id={fields.common.name()} type="text" />
<Errors field={fields.common} className="error" />

<label htmlFor={fields.size.name()}>Size</label>
<select {...fields.size.register()} id={fields.size.name()}>
<option value="small">Small</option>
<option value="large">Large</option>
</select>
<Errors field={fields.size} className="error" />

{getValue(fields.size) === 'small' ? <>
<label htmlFor={fields.smallProperty.name()}>Small property</label>
<input {...fields.smallProperty.register()} id={fields.smallProperty.name()} type="text" />
<Errors field={fields.smallProperty} className="error" />
</> : <>
<label htmlFor={fields.largeProperty.name()}>Large property</label>
<input {...fields.largeProperty.register()} id={fields.largeProperty.name()} type="text" />
<Errors field={fields.largeProperty} className="error" />
</>}

<button>Save changes</button>
</form>

<Output isDirty={isDirty} fields={fields} />
</>
}

export default Intersection
38 changes: 29 additions & 9 deletions lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,19 @@ import { z } from 'zod'
// A Zod object that can hold nested data
type AnyZodContainer = z.AnyZodObject | z.AnyZodTuple | z.ZodArray<any> | z.ZodRecord | z.ZodMap | z.ZodSet

// Intersect everything inside an array after running each through the FieldChain type
type FieldChainEach<Schema extends z.ZodType, ArraySchema extends z.ZodType[]> = ArraySchema extends [infer First extends z.ZodType, ...infer Rest extends z.ZodType[]]
? FieldChain<Schema, First> & FieldChainEach<Schema, Rest>
: unknown

// Creates the type for the field chain by recusively travelling through the Zod schema
type RecursiveFieldChain<Schema extends z.ZodType, LeafValue> =
z.infer<Schema> extends LeafValue ? z.infer<Schema>
: Schema extends z.AnyZodTuple ? { [K in keyof z.infer<Schema>]: FieldChain<Schema['_type'][K]> }
: Schema extends z.ZodArray<any> ? { [k: number]: FieldChain<Schema['_def']['type']> }
: Schema extends z.AnyZodObject ? { [K in keyof z.infer<Schema>]: FieldChain<Schema['shape'][K]> }
: Schema extends z.ZodIntersection<any, any> ? FieldChain<Schema, Schema['_def']['left']> & FieldChain<Schema, Schema['_def']['right']>
: Schema extends (z.ZodUnion<any> | z.ZodDiscriminatedUnion<string, any>) ? FieldChainEach<Schema, Schema['options']>
: Schema extends (z.ZodDefault<AnyZodContainer> | z.ZodOptional<AnyZodContainer> | z.ZodNullable<AnyZodContainer>) ? FieldChain<Schema, Schema['_def']['innerType']>
: Schema extends z.ZodEffects<AnyZodContainer> ? FieldChain<Schema, Schema['_def']['schema']>
: Schema extends z.ZodLazy<AnyZodContainer> ? FieldChain<Schema, ReturnType<Schema['_def']['getter']>>
Expand Down Expand Up @@ -54,11 +61,25 @@ export type PartialObject<T> = T extends any[] ? T : Partial<T>
/** Excludes undefined from a type, but keeps null */
export type NonUndefined<T> = 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)

Expand Down Expand Up @@ -112,17 +133,16 @@ export const fieldChain = <S extends z.ZodType>(
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...

Expand Down

0 comments on commit be35389

Please sign in to comment.