From 55d6f9a66c0fe9557ce04973e94cfc592dfe102b Mon Sep 17 00:00:00 2001 From: roux-lon Date: Wed, 11 Nov 2026 12:20:26 +0700 Subject: [PATCH 1/2] fix(form-core): keep optional schema properties optional in looseSchema InferUnionObject mapped over a union of keys, which drops the `?` modifier. An optional schema property such as `myProp?: string` became a required key in the merged form type, so the same schema was rejected in `validators[].run`. Take the key modifiers from a homomorphic helper so optionality is preserved. Closes #2475 Co-Authored-By: Claude Sonnet 5.5 --- .changeset/quiet-keys-stay.md | 5 ++++ packages/form-core/src/utils.public.ts | 14 +++++++-- packages/form-core/tests/validation.test-d.ts | 29 +++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 .changeset/quiet-keys-stay.md diff --git a/.changeset/quiet-keys-stay.md b/.changeset/quiet-keys-stay.md new file mode 100644 index 0000000000..4c7d4f8049 --- /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 0955abaebf..3fa54430e1 100644 --- a/packages/form-core/src/utils.public.ts +++ b/packages/form-core/src/utils.public.ts @@ -35,8 +35,18 @@ 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]: 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`. A key is optional there only when +// every side that declares it marks it optional. 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 7adec17de0..eba9b0df2d 100644 --- a/packages/form-core/tests/validation.test-d.ts +++ b/packages/form-core/tests/validation.test-d.ts @@ -263,6 +263,35 @@ 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('types callback values when mixed with a loose schema', () => { type LooseValue = { name: string From 09ab32195f635ac8f372949c0fdb955f28910f91 Mon Sep 17 00:00:00 2001 From: roux-lon Date: Wed, 11 Nov 2026 13:05:35 +0700 Subject: [PATCH 2/2] fix(form-core): keep schema-optional properties optional when defaults set them A property supplied by `defaultValues` was required in the merged form type even when the schema marks it optional, so the same schema was rejected in `validators[].run`. Keys declared by the schema now take its optional modifier; keys only in `defaultValues` keep theirs. Co-Authored-By: Claude Sonnet 5.5 --- packages/form-core/src/utils.public.ts | 9 ++++++--- packages/form-core/tests/validation.test-d.ts | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/form-core/src/utils.public.ts b/packages/form-core/src/utils.public.ts index 3fa54430e1..4f5ed0cbfd 100644 --- a/packages/form-core/src/utils.public.ts +++ b/packages/form-core/src/utils.public.ts @@ -38,12 +38,15 @@ type InferUnionObject< > = InferUnionProps< TBase, TIncoming, - { [K in keyof TBase]: unknown } & { [K in keyof TIncoming]: unknown } + { + [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`. A key is optional there only when -// every side that declares it marks it optional. Mapping over +// 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 diff --git a/packages/form-core/tests/validation.test-d.ts b/packages/form-core/tests/validation.test-d.ts index eba9b0df2d..4c8a259ff3 100644 --- a/packages/form-core/tests/validation.test-d.ts +++ b/packages/form-core/tests/validation.test-d.ts @@ -292,6 +292,25 @@ describe('formOptions', () => { }>() }) + 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