diff --git a/.changeset/quiet-keys-stay.md b/.changeset/quiet-keys-stay.md new file mode 100644 index 000000000..4c7d4f804 --- /dev/null +++ b/.changeset/quiet-keys-stay.md @@ -0,0 +1,5 @@ +--- +'@tanstack/form-core': patch +--- + +Fix: `formOptions.looseSchema` no longer turns optional schema properties into required ones, so the same schema can be passed to `validators[].run`. diff --git a/packages/form-core/src/utils.public.ts b/packages/form-core/src/utils.public.ts index 0955abaeb..4f5ed0cbf 100644 --- a/packages/form-core/src/utils.public.ts +++ b/packages/form-core/src/utils.public.ts @@ -35,8 +35,21 @@ export type InferUnion = TBase extends BuiltInType type InferUnionObject< in out TBase extends object, in out TIncoming extends object, -> = { - [K in keyof TBase | keyof TIncoming]: K extends keyof TBase +> = InferUnionProps< + TBase, + TIncoming, + { + [K in keyof TBase as K extends keyof TIncoming ? never : K]: unknown + } & { [K in keyof TIncoming]: unknown } +> + +// Mapping over `keyof TKeys` (a type parameter) is homomorphic, so each key +// keeps the `?` modifier it has in `TKeys`. Keys that `TIncoming` declares take +// its modifier, so a schema input that omits a property stays assignable. +// Keys only in `TBase` keep theirs. Mapping over +// `keyof TBase | keyof TIncoming` directly would drop every modifier. +type InferUnionProps = { + [K in keyof TKeys]: K extends keyof TBase ? K extends keyof TIncoming ? InferUnion : TBase[K] diff --git a/packages/form-core/tests/validation.test-d.ts b/packages/form-core/tests/validation.test-d.ts index 7adec17de..4c8a259ff 100644 --- a/packages/form-core/tests/validation.test-d.ts +++ b/packages/form-core/tests/validation.test-d.ts @@ -263,6 +263,54 @@ describe('formOptions', () => { }>() }) + it('keeps optional schema properties optional with a loose schema', () => { + const schema = z.object({ + required: z.string(), + optional: z.string().optional(), + nested: z.object({ + optional: z.number().optional(), + }), + }) + const options = formOptions.looseSchema(schema, { + defaultValues: {}, + validators: [ + { + run: schema, + triggers: ['change'], + }, + ], + }) + + expectTypeOf(options.defaultValues).toEqualTypeOf<{ + required: string | undefined + optional?: string | undefined + nested: + | { + optional?: number | undefined + } + | undefined + }>() + }) + + it('keeps optional schema properties optional when defaults provide them', () => { + const schema = z.object({ + optional: z.string().optional(), + }) + const options = formOptions.looseSchema(schema, { + defaultValues: { optional: 'value' }, + validators: [ + { + run: schema, + triggers: ['change'], + }, + ], + }) + + expectTypeOf(options.defaultValues).toEqualTypeOf<{ + optional?: string | undefined + }>() + }) + it('types callback values when mixed with a loose schema', () => { type LooseValue = { name: string