diff --git a/.changeset/odd-comics-push.md b/.changeset/odd-comics-push.md new file mode 100644 index 0000000000..25de60ef11 --- /dev/null +++ b/.changeset/odd-comics-push.md @@ -0,0 +1,5 @@ +--- +'@tanstack/form-core': patch +--- + +Fix: Schema output type now properly guards against dynamically enabled validators diff --git a/packages/form-core/src/validation.public.ts b/packages/form-core/src/validation.public.ts index 655a4621d5..457fb865ed 100644 --- a/packages/form-core/src/validation.public.ts +++ b/packages/form-core/src/validation.public.ts @@ -636,6 +636,19 @@ type TryGetSchemaOutput = TValidator extends { ? TOutput : undefined +type TryGetSubmitSchemaOutput = + TryGetSchemaOutput extends infer TOutput + ? TValidator extends { readonly runOnSubmit: infer TRunOnSubmit } + ? [TRunOnSubmit] extends [false] + ? undefined // User explicitly set runOnSubmit: false -> guaranteed undefined + : TRunOnSubmit extends (...args: Array) => boolean + ? TOutput | undefined // Callback could dynamically be true or false -> union + : false extends TRunOnSubmit + ? TOutput | undefined // the explicit variable is boolean, so also dynamic -> union + : TOutput + : TOutput // default, which is guaranteed present + : never + type ValidatorTriggers = TValidator extends { readonly triggers: infer TTriggers } @@ -667,9 +680,7 @@ type MappedSchemaOutputs> = { [K in keyof TValidators]: TValidators[K] extends { readonly run: any } - ? TValidators[K] extends { readonly runOnSubmit: false } - ? undefined - : TryGetSchemaOutput + ? TryGetSubmitSchemaOutput : never } diff --git a/packages/form-core/tests/validation.test-d.ts b/packages/form-core/tests/validation.test-d.ts index bc1b8c0b9d..71b099acab 100644 --- a/packages/form-core/tests/validation.test-d.ts +++ b/packages/form-core/tests/validation.test-d.ts @@ -1285,6 +1285,72 @@ describe('validator type transforms', () => { >() }) + it('makes schema outputs optional when runOnSubmit is dynamic', () => { + const dynamicRunOnSubmit = true as boolean + const vs = defineFormValidators([ + { + run: z.object({ name: z.string() }), + triggers: [], + }, + { + run: z.object({ name: z.string() }), + triggers: [], + runOnSubmit: true, + }, + { + run: z.object({ name: z.string() }), + triggers: [], + runOnSubmit: false, + }, + { + run: z.object({ name: z.string() }), + triggers: [], + runOnSubmit: dynamicRunOnSubmit, + }, + { + run: z.object({ name: z.string() }), + triggers: [], + runOnSubmit: () => true, + }, + { + run: z.object({ name: z.string() }), + triggers: [], + runOnSubmit: () => false, + }, + { + run: z.object({ name: z.string() }), + triggers: [], + runOnSubmit: () => dynamicRunOnSubmit, + }, + { + run: z.object({ name: z.string() }), + triggers: [], + runOnSubmit: undefined, + }, + ]) + + type Output = { name: string } + type Outputs = ToFormSchemaOutputs + type Expected = readonly [ + // An omitted runOnSubmit defaults to true, so the output is guaranteed. + Output, + // A literal true always runs the validator during submit. + Output, + // A literal false always skips the validator during submit. + undefined, + // A broad boolean may skip the validator at runtime. + Output | undefined, + // Predicates currently always assume dynamic results. Types are here if you plan to tighten it in the future. + Output | undefined, + Output | undefined, + Output | undefined, + // An explicit undefined receives the same true default as an omitted property. + Output, + ] + + expectTypeOf().toEqualTypeOf() + }) + it('should transform field validators', () => { const vs = defineFieldValidators([ {