From 5e3fb5de318bdf401f0a1268d2c32d1371dedf77 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:39:46 +0200 Subject: [PATCH 1/2] revert: allow easier type errors for formOptions again --- .changeset/forty-eyes-fall.md | 2 - packages/form-core/src/utils.public.ts | 93 ++++++------------- packages/form-core/tests/validation.test-d.ts | 13 +-- 3 files changed, 28 insertions(+), 80 deletions(-) diff --git a/.changeset/forty-eyes-fall.md b/.changeset/forty-eyes-fall.md index 332b62484..3c5b2f82a 100644 --- a/.changeset/forty-eyes-fall.md +++ b/.changeset/forty-eyes-fall.md @@ -11,6 +11,4 @@ Refactor: Form option types are now no longer adapter-specific -Fix: `formOptions.looseSchema`/`strictSchema` now error on missing schema - Fix: `formOptions.looseSchema` now accepts optional `defaultValues` props diff --git a/packages/form-core/src/utils.public.ts b/packages/form-core/src/utils.public.ts index 22ccdc1c0..4d8a78029 100644 --- a/packages/form-core/src/utils.public.ts +++ b/packages/form-core/src/utils.public.ts @@ -1,5 +1,4 @@ import type { FormOptions } from './FormApi/FormApi.public' -import type { StandardSchemaV1 } from './standardSchema.public' import type { FormValidators } from './validation.public' type Primitive = string | number | boolean | bigint | symbol | null | undefined @@ -51,40 +50,6 @@ export type FormValidatorData> = export type NullableSchemaData> = Editable> -type FormValidatorsWithStandardSchema< - TFormValidators extends FormValidators, -> = - Extract< - TFormValidators[number], - { readonly run: StandardSchemaV1 } - > extends never - ? never - : TFormValidators - -/** - * Form options accepted by a schema mode when `validators` is statically known - * to contain at least one Standard Schema. - * - * Empty and callback-only validator collections are rejected because they - * cannot provide schema-owned form data inference. Application code normally - * receives this type through `formOptions.strictSchema`, - * `formOptions.looseSchema`, or an equivalent `appFormOptions` method rather - * than naming it directly. - * - * @typeParam TFormData - Library-managed. Do not specify explicitly. - * @typeParam TFormValidators - Library-managed. Do not specify explicitly. - * @typeParam TSubmitReturn - Library-managed. Do not specify explicitly. - * @typeParam TComponents - Library-managed. Do not specify explicitly. - */ -export type StandardSchemaFormOptions< - TFormData, - TFormValidators extends FormValidators, - TSubmitReturn, - TComponents, -> = FormOptions & { - validators: FormValidatorsWithStandardSchema -} - /** * Infers the form data type from a Standard Schema validator and requires * `defaultValues` to match the schema input. @@ -96,13 +61,14 @@ export type StandardSchemaFormOptions< * At runtime, this returns the original options object and does not run the * schema. * - * `validators` must contain at least one Standard Schema to provide the type - * inference and perform validation. + * Include the schema in `validators` to provide the type inference and + * perform validation. * * @remarks - * **Important:** Although schema-mode inputs require `validators`, this - * returns a type normalized to `FormOptions`, where `validators` is optional. - * This tradeoff enables safer inference and reuse. + * **Important:** Although this returns the original object unchanged at + * runtime, its type is normalized to `FormOptions`. Optional properties such + * as `validators` therefore remain optional even when supplied. This + * tradeoff enables safer inference and reuse. * * @example * ```ts @@ -132,12 +98,7 @@ export type FormOptionsStrictSchemaFn = < TFormData extends FormValidatorData, TSubmitReturn, >( - options: StandardSchemaFormOptions< - TFormData, - TFormValidators, - TSubmitReturn, - unknown - >, + options: FormOptions, ) => FormOptions< FormValidatorData, TFormValidators, @@ -158,13 +119,14 @@ export type FormOptionsStrictSchemaFn = < * At runtime, this returns the original options object and does not run the * schema. * - * `validators` must contain at least one Standard Schema to provide the type - * inference and perform validation. + * Include the schema in `validators` to provide the type inference and + * perform validation. * * @remarks - * **Important:** Although schema-mode inputs require `validators`, this - * returns a type normalized to `FormOptions`, where `validators` is optional. - * This tradeoff enables safer inference and reuse. + * **Important:** Although this returns the original object unchanged at + * runtime, its type is normalized to `FormOptions`. Optional properties such + * as `validators` therefore remain optional even when supplied. This + * tradeoff enables safer inference and reuse. * * @example * ```ts @@ -194,12 +156,7 @@ export type FormOptionsLooseSchemaFn = < const TFormData extends NullableSchemaData, TSubmitReturn, >( - options: StandardSchemaFormOptions< - TFormData, - TFormValidators, - TSubmitReturn, - unknown - >, + options: FormOptions, ) => FormOptions< InferUnion>, TFormValidators, @@ -256,13 +213,14 @@ export interface FormOptionsApi { * At runtime, this returns the original options object and does not run the * schema. * - * `validators` must contain at least one Standard Schema to provide the type - * inference and perform validation. + * Include the schema in `validators` to provide the type inference and + * perform validation. * * @remarks - * **Important:** Although schema-mode inputs require `validators`, this - * returns a type normalized to `FormOptions`, where `validators` is optional. - * This tradeoff enables safer inference and reuse. + * **Important:** Although this returns the original object unchanged at + * runtime, its type is normalized to `FormOptions`. Optional properties such + * as `validators` therefore remain optional even when supplied. This + * tradeoff enables safer inference and reuse. * * @example * ```ts @@ -299,13 +257,14 @@ export interface FormOptionsApi { * At runtime, this returns the original options object and does not run the * schema. * - * `validators` must contain at least one Standard Schema to provide the type - * inference and perform validation. + * Include the schema in `validators` to provide the type inference and + * perform validation. * * @remarks - * **Important:** Although schema-mode inputs require `validators`, this - * returns a type normalized to `FormOptions`, where `validators` is optional. - * This tradeoff enables safer inference and reuse. + * **Important:** Although this returns the original object unchanged at + * runtime, its type is normalized to `FormOptions`. Optional properties such + * as `validators` therefore remain optional even when supplied. This + * tradeoff enables safer inference and reuse. * * @example * ```ts diff --git a/packages/form-core/tests/validation.test-d.ts b/packages/form-core/tests/validation.test-d.ts index eeeaa828c..b515ebca2 100644 --- a/packages/form-core/tests/validation.test-d.ts +++ b/packages/form-core/tests/validation.test-d.ts @@ -111,15 +111,6 @@ describe('formOptions', () => { expectTypeOf(options.defaultValues).toEqualTypeOf<{ name: string }>() }) - it('rejects strict schema options without a schema', () => { - // @ts-expect-error Schema modes require a Standard Schema validator. - const options = formOptions.strictSchema({ - defaultValues: { name: '' }, - }) - - void options - }) - it('allows loose schema defaults to omit properties', () => { const options = formOptions.looseSchema({ defaultValues: { @@ -150,8 +141,7 @@ describe('formOptions', () => { }>() }) - it('rejects loose schema options without a schema', () => { - // @ts-expect-error Schema modes require a Standard Schema validator. + it('accepts loose schema options without a schema', () => { const options = formOptions.looseSchema({ defaultValues: { name: '' }, }) @@ -159,6 +149,7 @@ describe('formOptions', () => { void options }) }) + describe('ErrorVisibility', () => { it('types callback scoped and pre-visibility field state', () => { const options: FormOptions< From ab1217e8ae30b65717fbbe5a6a8676cfdedc881e Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:02:25 +0200 Subject: [PATCH 2/2] feat: add type safer overload to form options --- .changeset/forty-eyes-fall.md | 8 +- packages/form-core/src/utils.public.ts | 393 ++++++++++++------ .../form-core/tests/validation-public.test.ts | 5 +- packages/form-core/tests/validation.test-d.ts | 227 +++++++++- .../src/AppForm/createFormHook.public.ts | 12 +- .../src/AppForm/createFormHook.public.ts | 12 +- .../react-form/tests/submit-return.test-d.tsx | 22 + .../src/AppForm/createFormHook.public.ts | 9 +- .../src/AppForm/createFormHook.public.ts | 9 +- .../src/AppForm/createFormHook.public.ts | 9 +- 10 files changed, 542 insertions(+), 164 deletions(-) diff --git a/.changeset/forty-eyes-fall.md b/.changeset/forty-eyes-fall.md index 3c5b2f82a..7fc9c302d 100644 --- a/.changeset/forty-eyes-fall.md +++ b/.changeset/forty-eyes-fall.md @@ -9,6 +9,10 @@ '@tanstack/vue-form': patch --- -Refactor: Form option types are now no longer adapter-specific +Refactor: Adapter `formOptions`/`appFormOptions` no longer shim the core types and runtime. -Fix: `formOptions.looseSchema` now accepts optional `defaultValues` props +Feat: `formOptions.looseSchema` and `formOptions.strictSchema` now accept a schema as +first parameter. This locks down inference to get the best type safety out of it vs. the object alone. + +Fix: `formOptions.looseSchema` now allows `defaultValues` as optional property too instead of +requiring it to be explicitly undefined. diff --git a/packages/form-core/src/utils.public.ts b/packages/form-core/src/utils.public.ts index 4d8a78029..2a4f64ae9 100644 --- a/packages/form-core/src/utils.public.ts +++ b/packages/form-core/src/utils.public.ts @@ -1,4 +1,5 @@ import type { FormOptions } from './FormApi/FormApi.public' +import type { StandardSchemaV1 } from './standardSchema.public' import type { FormValidators } from './validation.public' type Primitive = string | number | boolean | bigint | symbol | null | undefined @@ -50,119 +51,253 @@ export type FormValidatorData> = export type NullableSchemaData> = Editable> +type StandardSchemaInput> = + TSchema extends StandardSchemaV1 ? TInput : never + +type LooseSchemaFormOptions< + TSchemaInput, + TFormData extends Editable, + TFormValidators extends FormValidators< + NoInfer> + >, + TSubmitReturn, +> = Omit< + FormOptions< + InferUnion, + TFormValidators, + TSubmitReturn, + unknown + >, + 'defaultValues' +> & { + defaultValues: TFormData +} + /** - * Infers the form data type from a Standard Schema validator and requires - * `defaultValues` to match the schema input. + * The overloads used to type strict schema form options. * - * Use this when the schema represents an input-to-output pipeline. Raw form - * state remains available as `value`; read each validator's parsed output - * from the corresponding `schemaOutputs` entry during submission. + * Both overloads return the original object unchanged at runtime, but + * normalize its type to `FormOptions`. Optional properties such as + * `validators` therefore remain optional in the returned type. * - * At runtime, this returns the original options object and does not run the - * schema. - * - * Include the schema in `validators` to provide the type inference and - * perform validation. - * - * @remarks - * **Important:** Although this returns the original object unchanged at - * runtime, its type is normalized to `FormOptions`. Optional properties such - * as `validators` therefore remain optional even when supplied. This - * tradeoff enables safer inference and reuse. - * - * @example - * ```ts - * const profileOptions = formOptions.strictSchema({ - * defaultValues: { name: '' }, - * validators: [ - * { - * triggers: ['change'], - * run: z.object({ name: z.string().min(1) }), - * }, - * ], - * onSubmit: ({ schemaOutputs }) => saveProfile(schemaOutputs[0]), - * }) - * ``` - * - * @returns The original options object, normalized to `FormOptions` with the - * schema's input shape. - * @typeParam TFormValidators - Library-managed. Do not specify explicitly. - * @typeParam TFormData - Library-managed. Do not specify explicitly. - * @typeParam TSubmitReturn - Library-managed. Do not specify explicitly. * @typeParam TComponents - Library-managed. Do not specify explicitly. */ -export type FormOptionsStrictSchemaFn = < - const TFormValidators extends FormValidators, - // Not quite sure why, but using FormValidatorData directly in the generic breaks things. - // Probably something recursive going on that resolves it to `never`? - TFormData extends FormValidatorData, - TSubmitReturn, ->( - options: FormOptions, -) => FormOptions< - FormValidatorData, - TFormValidators, - TSubmitReturn, - TComponents -> +export type FormOptionsStrictSchemaFn = { + /** + * Types strict form options using a separate schema as the source of the + * form data type. + * + * The schema input fixes the form data type before the options are inferred, + * so `defaultValues` and each callback validator's `value` use the exact + * schema input type. + * + * The first argument is used only by TypeScript and is ignored at runtime. + * Include the schema in `validators` as well when it should validate the + * form. Parsed results are available in the corresponding `schemaOutputs` + * entries during submission. + * + * @example + * ```ts + * const profileSchema = z.object({ name: z.string().min(1) }) + * const profileOptions = formOptions.strictSchema(profileSchema, { + * defaultValues: { name: '' }, + * validators: [ + * { triggers: ['change'], run: profileSchema }, + * { + * triggers: ['change'], + * run: ({ value }) => + * value.name.length === 0 ? 'Name is required' : undefined, + * }, + * ], + * }) + * ``` + * + * @param schema - Supplies the form data type without registering a + * validator. + * @param options - The form options to type against the schema input. + * @returns The original options object, normalized to `FormOptions` with the + * schema input as its form data type. + * @typeParam TSchema - Library-managed. Do not specify explicitly. + * @typeParam TFormValidators - Library-managed. Do not specify explicitly. + * @typeParam TSubmitReturn - Library-managed. Do not specify explicitly. + */ + < + const TSchema extends StandardSchemaV1, + const TFormValidators extends FormValidators>, + TSubmitReturn, + >( + schema: TSchema, + options: FormOptions< + StandardSchemaInput, + TFormValidators, + TSubmitReturn, + unknown + >, + ): FormOptions< + StandardSchemaInput, + TFormValidators, + TSubmitReturn, + TComponents + > + + /** + * Types strict form options by inferring the form data type from the schemas + * in `validators`. + * + * `defaultValues` must match the schemas' input type. + * + * @important TypeScript inference for this overload can break when + * `validators` contains callback validators or is omitted. Callback + * validator `value` parameters may become `any`, which can also make the + * inferred form data type less precise. For mixed or callback-only + * validators, or no validators, pass a typing schema first and the options + * second. + * + * @example + * ```ts + * const profileOptions = formOptions.strictSchema({ + * defaultValues: { name: '' }, + * validators: [{ triggers: ['submit'], run: profileSchema }], + * }) + * ``` + * + * @param options - Form options whose schemas supply the form data type. + * @returns The original options object, normalized to `FormOptions` with the + * inferred schema input as its form data type. + * @typeParam TFormValidators - Library-managed. Do not specify explicitly. + * @typeParam TFormData - Library-managed. Do not specify explicitly. + * @typeParam TSubmitReturn - Library-managed. Do not specify explicitly. + */ + < + const TFormValidators extends FormValidators, + // Not quite sure why, but using FormValidatorData directly in the generic breaks things. + // Probably something recursive going on that resolves it to `never`? + TFormData extends FormValidatorData, + TSubmitReturn, + >( + options: FormOptions, + ): FormOptions< + FormValidatorData, + TFormValidators, + TSubmitReturn, + TComponents + > +} /** - * Infers the form data shape from a Standard Schema validator while allowing - * editable defaults to omit properties or contain `null` or `undefined` - * values. - * - * Use this when the schema represents the final valid shape but the UI needs - * intermediate empty states, such as an unselected date. Raw form state - * remains available as `value`; read each validator's parsed output from the - * corresponding `schemaOutputs` entry during submission. + * The overloads used to type loose schema form options. * - * At runtime, this returns the original options object and does not run the - * schema. + * Both overloads return the original object unchanged at runtime, but + * normalize its type to `FormOptions`. Optional properties such as + * `validators` therefore remain optional in the returned type. * - * Include the schema in `validators` to provide the type inference and - * perform validation. - * - * @remarks - * **Important:** Although this returns the original object unchanged at - * runtime, its type is normalized to `FormOptions`. Optional properties such - * as `validators` therefore remain optional even when supplied. This - * tradeoff enables safer inference and reuse. - * - * @example - * ```ts - * const bookingOptions = formOptions.looseSchema({ - * defaultValues: { startDate: null }, - * validators: [ - * { - * triggers: ['blur'], - * run: z.object({ startDate: z.date() }), - * }, - * ], - * onSubmit: ({ schemaOutputs }) => saveBooking(schemaOutputs[0]), - * }) - * ``` - * - * @returns The original options object, normalized to `FormOptions` with - * omitted, nullable, and undefined editable states merged into the schema's - * input shape. - * @typeParam TFormValidators - Library-managed. Do not specify explicitly. - * @typeParam TFormData - Library-managed. Do not specify explicitly. - * @typeParam TSubmitReturn - Library-managed. Do not specify explicitly. * @typeParam TComponents - Library-managed. Do not specify explicitly. - * */ -export type FormOptionsLooseSchemaFn = < - const TFormValidators extends FormValidators, - const TFormData extends NullableSchemaData, - TSubmitReturn, ->( - options: FormOptions, -) => FormOptions< - InferUnion>, - TFormValidators, - TSubmitReturn, - TComponents -> +export type FormOptionsLooseSchemaFn = { + /** + * Types loose schema form options using a separate schema as the source of + * the final valid form shape. + * + * `defaultValues` infer an editable form shape constrained by the schema + * input, so properties may be omitted or contain `null` or `undefined`. + * Callback validator `value` parameters use that editable shape merged with + * the schema input. + * + * The first argument is used only by TypeScript and is ignored at runtime. + * Include the schema in `validators` as well when it should validate the + * form. Parsed results are available in the corresponding `schemaOutputs` + * entries during submission. + * + * @example + * ```ts + * const bookingSchema = z.object({ startDate: z.date() }) + * const bookingOptions = formOptions.looseSchema(bookingSchema, { + * defaultValues: { startDate: null }, + * validators: [ + * { triggers: ['blur'], run: bookingSchema }, + * { + * triggers: ['change'], + * run: ({ value }) => + * value.startDate === null ? 'Choose a date' : undefined, + * }, + * ], + * }) + * ``` + * + * @param schema - Supplies the final valid form shape without registering a + * validator. + * @param options - The form options used to infer the editable form shape. + * @returns The original options object, normalized to `FormOptions` with the + * editable states merged into the schema input. + * @typeParam TSchema - Library-managed. Do not specify explicitly. + * @typeParam TFormData - Library-managed. Do not specify explicitly. + * @typeParam TFormValidators - Library-managed. Do not specify explicitly. + * @typeParam TSubmitReturn - Library-managed. Do not specify explicitly. + */ + < + const TSchema extends StandardSchemaV1, + const TFormData extends Editable>, + const TFormValidators extends FormValidators< + NoInfer>> + >, + TSubmitReturn, + >( + schema: TSchema, + options: LooseSchemaFormOptions< + StandardSchemaInput, + TFormData, + TFormValidators, + TSubmitReturn + >, + ): FormOptions< + InferUnion>, + TFormValidators, + TSubmitReturn, + TComponents + > + + /** + * Types loose schema form options by inferring the final valid form shape + * from the schemas in `validators`. + * + * `defaultValues` may omit schema properties or use `null` or `undefined` + * for intermediate editing states. + * + * @important TypeScript inference for this overload can break when + * `validators` contains callback validators or is omitted. Callback + * validator `value` parameters may become `any`, which can also make the + * inferred form data type less precise. For mixed or callback-only + * validators, or no validators, pass a typing schema first and the options + * second. + * + * @example + * ```ts + * const bookingOptions = formOptions.looseSchema({ + * defaultValues: { startDate: null }, + * validators: [{ triggers: ['submit'], run: bookingSchema }], + * }) + * ``` + * + * @param options - Form options whose schemas supply the final valid shape. + * @returns The original options object, normalized to `FormOptions` with the + * editable states merged into the inferred schema input. + * @typeParam TFormValidators - Library-managed. Do not specify explicitly. + * @typeParam TFormData - Library-managed. Do not specify explicitly. + * @typeParam TSubmitReturn - Library-managed. Do not specify explicitly. + */ + < + const TFormValidators extends FormValidators, + const TFormData extends NullableSchemaData, + TSubmitReturn, + >( + options: FormOptions, + ): FormOptions< + InferUnion>, + TFormValidators, + TSubmitReturn, + TComponents + > +} /** * The callable API exposed by `formOptions`, including its schema-driven @@ -210,11 +345,12 @@ export interface FormOptionsApi { * state remains available as `value`; read each validator's parsed output * from the corresponding `schemaOutputs` entry during submission. * - * At runtime, this returns the original options object and does not run the - * schema. - * - * Include the schema in `validators` to provide the type inference and - * perform validation. + * Pass the schema as the first argument when the options also contain + * callback validators. This fixes the form data to the schema input before + * the options are inferred, so each callback receives a typed `value`. The + * first argument is ignored at runtime; include the schema in `validators` + * when it should run. The single-argument overload continues to infer the + * schema from `validators`. * * @remarks * **Important:** Although this returns the original object unchanged at @@ -224,12 +360,18 @@ export interface FormOptionsApi { * * @example * ```ts - * const profileOptions = formOptions.strictSchema({ + * const profileSchema = z.object({ name: z.string().min(1) }) + * const profileOptions = formOptions.strictSchema(profileSchema, { * defaultValues: { name: '' }, * validators: [ * { * triggers: ['change'], - * run: z.object({ name: z.string().min(1) }), + * run: profileSchema, + * }, + * { + * triggers: ['change'], + * run: ({ value }) => + * value.name.length === 0 ? 'Name is required' : undefined, * }, * ], * onSubmit: ({ schemaOutputs }) => saveProfile(schemaOutputs[0]), @@ -238,6 +380,7 @@ export interface FormOptionsApi { * * @returns The original options object, normalized to `FormOptions` with the * schema's input shape. + * @typeParam TSchema - Library-managed. Do not specify explicitly. * @typeParam TFormValidators - Library-managed. Do not specify explicitly. * @typeParam TFormData - Library-managed. Do not specify explicitly. * @typeParam TSubmitReturn - Library-managed. Do not specify explicitly. @@ -254,11 +397,12 @@ export interface FormOptionsApi { * remains available as `value`; read each validator's parsed output from the * corresponding `schemaOutputs` entry during submission. * - * At runtime, this returns the original options object and does not run the - * schema. - * - * Include the schema in `validators` to provide the type inference and - * perform validation. + * Pass the schema as the first argument when the options also contain + * callback validators. `defaultValues` infer an editable form shape + * constrained by the schema input, and callbacks receive that shape merged + * with the schema input. The first argument is ignored at runtime; include + * the schema in `validators` when it should run. The single-argument overload + * continues to infer the schema from `validators`. * * @remarks * **Important:** Although this returns the original object unchanged at @@ -268,12 +412,18 @@ export interface FormOptionsApi { * * @example * ```ts - * const bookingOptions = formOptions.looseSchema({ + * const bookingSchema = z.object({ startDate: z.date() }) + * const bookingOptions = formOptions.looseSchema(bookingSchema, { * defaultValues: { startDate: null }, * validators: [ * { * triggers: ['blur'], - * run: z.object({ startDate: z.date() }), + * run: bookingSchema, + * }, + * { + * triggers: ['change'], + * run: ({ value }) => + * value.startDate === null ? 'Choose a date' : undefined, * }, * ], * onSubmit: ({ schemaOutputs }) => saveBooking(schemaOutputs[0]), @@ -283,6 +433,7 @@ export interface FormOptionsApi { * @returns The original options object, normalized to `FormOptions` with * omitted, nullable, and undefined editable states merged into the schema's * input shape. + * @typeParam TSchema - Library-managed. Do not specify explicitly. * @typeParam TFormValidators - Library-managed. Do not specify explicitly. * @typeParam TFormData - Library-managed. Do not specify explicitly. * @typeParam TSubmitReturn - Library-managed. Do not specify explicitly. @@ -328,11 +479,11 @@ export interface FormOptionsApi { * }) * ``` */ -const formOptions = ((opts) => { - return opts -}) as FormOptionsApi +const formOptions = ((opts) => opts) as FormOptionsApi -formOptions.strictSchema = (opts) => opts -formOptions.looseSchema = (opts) => opts as never +formOptions.strictSchema = ((schemaOrOpts: unknown, opts?: unknown) => + opts ?? schemaOrOpts) as never +formOptions.looseSchema = ((schemaOrOpts: unknown, opts?: unknown) => + opts ?? schemaOrOpts) as never export { formOptions } diff --git a/packages/form-core/tests/validation-public.test.ts b/packages/form-core/tests/validation-public.test.ts index 34273b0e5..3546bb601 100644 --- a/packages/form-core/tests/validation-public.test.ts +++ b/packages/form-core/tests/validation-public.test.ts @@ -12,11 +12,12 @@ describe('validation public helpers', () => { it('returns form options unchanged at runtime', () => { const options = { defaultValues: { name: 'Ada' } } const triggers: Array<'change'> = ['change'] + const schema = z.object({ name: z.string() }) const schemaOptions = { ...options, validators: [ { - run: z.object({ name: z.string() }), + run: schema, triggers, }, ], @@ -25,6 +26,8 @@ describe('validation public helpers', () => { expect(formOptions(options)).toBe(options) expect(formOptions.strictSchema(schemaOptions)).toBe(schemaOptions) expect(formOptions.looseSchema(schemaOptions)).toBe(schemaOptions) + expect(formOptions.strictSchema(schema, schemaOptions)).toBe(schemaOptions) + expect(formOptions.looseSchema(schema, schemaOptions)).toBe(schemaOptions) }) it('creates validators by pairing options with run functions', () => { diff --git a/packages/form-core/tests/validation.test-d.ts b/packages/form-core/tests/validation.test-d.ts index b515ebca2..f3cb68524 100644 --- a/packages/form-core/tests/validation.test-d.ts +++ b/packages/form-core/tests/validation.test-d.ts @@ -111,6 +111,124 @@ describe('formOptions', () => { expectTypeOf(options.defaultValues).toEqualTypeOf<{ name: string }>() }) + it('infers schema input and output from schema-only validators', () => { + const schema = z.object({ age: z.string().transform(Number) }) + const options = formOptions.strictSchema({ + defaultValues: { age: '' }, + validators: [{ run: schema, triggers: ['change'] }], + onSubmit: ({ value, schemaOutputs }) => { + expectTypeOf(value).toEqualTypeOf<{ age: string }>() + expectTypeOf(schemaOutputs).toEqualTypeOf() + }, + }) + + expectTypeOf(options.defaultValues).toEqualTypeOf<{ age: string }>() + }) + + it('types callback values when mixed with a strict schema', () => { + type StrictValue = { + name: string + age: string + } + const schema = z.object({ + name: z.string(), + age: z.string().transform(Number), + }) + + const options = formOptions.strictSchema(schema, { + defaultValues: { name: '', age: '' }, + validators: [ + { + run: ({ value }) => { + expectTypeOf(value).toEqualTypeOf() + return value.name.length === 0 ? 'Name is required' : undefined + }, + triggers: ['change'], + }, + { + run: schema, + triggers: ['change'], + }, + { + run: ({ value }) => { + expectTypeOf(value).toEqualTypeOf() + return value.age.length === 0 ? 'Age is required' : undefined + }, + triggers: ['blur'], + }, + ], + onSubmit: ({ value, schemaOutputs }) => { + expectTypeOf(value).toEqualTypeOf() + expectTypeOf(schemaOutputs).toEqualTypeOf< + readonly [undefined, { name: string; age: number }, undefined] + >() + }, + }) + + expectTypeOf(options.defaultValues).toEqualTypeOf() + }) + + it('types callback-only validators from a strict schema argument', () => { + type StrictValue = { name: string } + const schema = z.object({ name: z.string() }) + const options = formOptions.strictSchema(schema, { + defaultValues: { name: '' }, + validators: [ + { + run: ({ value }) => { + expectTypeOf(value).toEqualTypeOf() + return value.name.length === 0 ? 'Name is required' : undefined + }, + triggers: ['change'], + }, + ], + onSubmit: ({ schemaOutputs }) => { + expectTypeOf(schemaOutputs).toEqualTypeOf() + }, + }) + + expectTypeOf(options.defaultValues).toEqualTypeOf() + }) + + it('types strict options without validators from a schema argument', () => { + type StrictValue = { name: string } + const schema = z.object({ name: z.string() }) + const options = formOptions.strictSchema(schema, { + defaultValues: { name: '' }, + errorVisibility: ({ state }) => { + expectTypeOf(state.values).toEqualTypeOf() + return state.values.name.length > 0 + }, + listeners: [ + { + run: ({ value }) => { + expectTypeOf(value).toEqualTypeOf() + }, + triggers: ['change'], + }, + ], + onSubmit: ({ value }) => { + expectTypeOf(value).toEqualTypeOf() + }, + onSubmitInvalid: ({ value }) => { + expectTypeOf(value).toEqualTypeOf() + }, + }) + + expectTypeOf(options.defaultValues).toEqualTypeOf() + }) + + it('rejects parsed output as strict schema defaults', () => { + const schema = z.object({ age: z.string().transform(Number) }) + + formOptions.strictSchema(schema, { + defaultValues: { + // @ts-expect-error Strict defaults must match the schema input. + age: 0, + }, + }) + }) + it('allows loose schema defaults to omit properties', () => { const options = formOptions.looseSchema({ defaultValues: { @@ -141,12 +259,113 @@ describe('formOptions', () => { }>() }) - it('accepts loose schema options without a schema', () => { - const options = formOptions.looseSchema({ - defaultValues: { name: '' }, + it('types callback values when mixed with a loose schema', () => { + type LooseValue = { + name: string + age: string | null + } + const schema = z.object({ + name: z.string(), + age: z.string().transform(Number), }) - void options + const options = formOptions.looseSchema(schema, { + defaultValues: { name: '', age: null }, + validators: [ + { + run: ({ value }) => { + expectTypeOf(value).toEqualTypeOf() + return value.name.length === 0 ? 'Name is required' : undefined + }, + triggers: ['change'], + }, + { + run: schema, + triggers: ['change'], + }, + { + run: ({ value }) => { + expectTypeOf(value).toEqualTypeOf() + return value.age !== null && value.age.length === 0 + ? 'Age is required' + : undefined + }, + triggers: ['blur'], + }, + ], + }) + + expectTypeOf(options.defaultValues).toEqualTypeOf() + }) + + it('types callback-only validators with omitted loose defaults', () => { + type LooseValue = { + name: string | undefined + address: { + city: string | null + postcode: number | undefined + } + } + const schema = z.object({ + name: z.string(), + address: z.object({ + city: z.string(), + postcode: z.number(), + }), + }) + const options = formOptions.looseSchema(schema, { + defaultValues: { address: { city: null } }, + validators: [ + { + run: ({ value }) => { + expectTypeOf(value).toEqualTypeOf() + return value.name === undefined ? 'Name is required' : undefined + }, + triggers: ['change'], + }, + ], + }) + + expectTypeOf(options.defaultValues).toEqualTypeOf() + }) + + it('types loose options without validators from a schema argument', () => { + type LooseValue = { name: string; age: string | null } + const schema = z.object({ name: z.string(), age: z.string() }) + const options = formOptions.looseSchema(schema, { + defaultValues: { name: '', age: null }, + errorVisibility: ({ state }) => { + expectTypeOf(state.values).toEqualTypeOf() + return state.values.name.length > 0 + }, + listeners: [ + { + run: ({ value }) => { + expectTypeOf(value).toEqualTypeOf() + }, + triggers: ['change'], + }, + ], + onSubmit: ({ value }) => { + expectTypeOf(value).toEqualTypeOf() + }, + onSubmitInvalid: ({ value }) => { + expectTypeOf(value).toEqualTypeOf() + }, + }) + + expectTypeOf(options.defaultValues).toEqualTypeOf() + }) + + it('rejects values outside the editable loose schema shape', () => { + const schema = z.object({ age: z.string() }) + + formOptions.looseSchema(schema, { + defaultValues: { + // @ts-expect-error Loose defaults must remain editable schema values. + age: false, + }, + }) }) }) diff --git a/packages/preact-form/src/AppForm/createFormHook.public.ts b/packages/preact-form/src/AppForm/createFormHook.public.ts index 7c984fd78..038324b03 100644 --- a/packages/preact-form/src/AppForm/createFormHook.public.ts +++ b/packages/preact-form/src/AppForm/createFormHook.public.ts @@ -1,3 +1,4 @@ +import { formOptions } from '@tanstack/form-core' import { useInternalForm } from '../PreactForm/PreactFormApi.lib' import { defineFieldGroup } from '../FieldGroup/withFields.public' import { createAppFormInitializer } from './initializeAppForm.lib' @@ -8,14 +9,7 @@ import type { UseAppFormHook, } from './createFormHookTypes.public' import type { FunctionComponent } from 'preact/compat' -import type { FormOptions, FormOptionsApi } from '@tanstack/form-core' - -const appFormOptions = ((opts) => { - return opts -}) as FormOptionsApi - -appFormOptions.strictSchema = (opts) => opts as never -appFormOptions.looseSchema = (opts) => opts as never +import type { FormOptions } from '@tanstack/form-core' export function createFormHook< const TFormComponents extends Record>, @@ -39,7 +33,7 @@ export function createFormHook< return { useFormContext: useFormContext as never, - appFormOptions, + appFormOptions: formOptions as never, defineAppFieldGroup: defineFieldGroup as never, useAppForm, } diff --git a/packages/react-form/src/AppForm/createFormHook.public.ts b/packages/react-form/src/AppForm/createFormHook.public.ts index 1ffd2c15d..a842120da 100644 --- a/packages/react-form/src/AppForm/createFormHook.public.ts +++ b/packages/react-form/src/AppForm/createFormHook.public.ts @@ -1,3 +1,4 @@ +import { formOptions } from '@tanstack/form-core' import { useInternalForm } from '../ReactForm/ReactFormApi.lib' import { defineFieldGroup } from '../FieldGroup/withFields.public' import { createAppFormInitializer } from './initializeAppForm.lib' @@ -8,14 +9,7 @@ import type { UseAppFormHook, } from './createFormHookTypes.public' import type { FunctionComponent } from 'react' -import type { FormOptions, FormOptionsApi } from '@tanstack/form-core' - -const appFormOptions = ((opts) => { - return opts -}) as FormOptionsApi - -appFormOptions.strictSchema = (opts) => opts as never -appFormOptions.looseSchema = (opts) => opts as never +import type { FormOptions } from '@tanstack/form-core' export function createFormHook< const TFormComponents extends Record>, @@ -39,7 +33,7 @@ export function createFormHook< return { useFormContext: useFormContext as never, - appFormOptions, + appFormOptions: formOptions as never, defineAppFieldGroup: defineFieldGroup as never, useAppForm, } diff --git a/packages/react-form/tests/submit-return.test-d.tsx b/packages/react-form/tests/submit-return.test-d.tsx index bd9cb236b..dda81ff88 100644 --- a/packages/react-form/tests/submit-return.test-d.tsx +++ b/packages/react-form/tests/submit-return.test-d.tsx @@ -198,6 +198,28 @@ describe('submit return', () => { formComponents: {}, }) + it('preserves registered components through schema-first overloads', () => { + const SubmitButton = () => null + const schema = z.object({ email: z.string() }) + const { appFormOptions: componentFormOptions } = createFormHook({ + fieldComponents: {}, + formComponents: { SubmitButton }, + }) + const strictOptions = componentFormOptions.strictSchema(schema, { + defaultValues: { email: '' }, + }) + const looseOptions = componentFormOptions.looseSchema(schema, { + defaultValues: { email: null }, + }) + + expectTypeOf< + ReactFormType['SubmitButton'] + >().toEqualTypeOf() + expectTypeOf< + ReactFormType['SubmitButton'] + >().toEqualTypeOf() + }) + it('should allow shared options to omit onSubmit', () => { const sharedOptionsWithoutSubmit = appFormOptions({ defaultValues: { email: '' }, diff --git a/packages/solid-form/src/AppForm/createFormHook.public.ts b/packages/solid-form/src/AppForm/createFormHook.public.ts index c14034ca0..a66d1cf82 100644 --- a/packages/solid-form/src/AppForm/createFormHook.public.ts +++ b/packages/solid-form/src/AppForm/createFormHook.public.ts @@ -1,3 +1,4 @@ +import { formOptions } from '@tanstack/form-core' import { createInternalForm } from '../SolidFormApi.lib' import { defineFieldGroup } from '../FieldGroup/withFields.public' import { createAppFormInitializer } from './initializeAppForm.lib' @@ -8,11 +9,7 @@ import type { UseAppFormHook, } from './createFormHookTypes.public' import type { Accessor, Component } from 'solid-js' -import type { FormOptions, FormOptionsApi } from '@tanstack/form-core' - -const appFormOptions = ((opts: unknown) => opts) as FormOptionsApi -appFormOptions.strictSchema = (opts) => opts as never -appFormOptions.looseSchema = (opts) => opts as never +import type { FormOptions } from '@tanstack/form-core' export function createFormHook< const TFormComponents extends Record>, @@ -37,7 +34,7 @@ export function createFormHook< return { useFormContext: useFormContext as never, - appFormOptions, + appFormOptions: formOptions as never, defineAppFieldGroup: defineFieldGroup as never, useAppForm, } diff --git a/packages/svelte-form/src/AppForm/createFormHook.public.ts b/packages/svelte-form/src/AppForm/createFormHook.public.ts index 248a3907b..6f9ff759e 100644 --- a/packages/svelte-form/src/AppForm/createFormHook.public.ts +++ b/packages/svelte-form/src/AppForm/createFormHook.public.ts @@ -1,3 +1,4 @@ +import { formOptions } from '@tanstack/form-core' import { createInternalForm } from '../createForm.svelte' import { defineFieldGroup } from '../FieldGroup/withFields.public' import { createAppFormInitializer } from './initializeAppForm.lib' @@ -8,11 +9,7 @@ import type { UseAppFormHook, } from './createFormHookTypes.public' import type { Component } from 'svelte' -import type { FormOptions, FormOptionsApi } from '@tanstack/form-core' - -const appFormOptions = ((opts: unknown) => opts) as FormOptionsApi -appFormOptions.strictSchema = (opts) => opts as never -appFormOptions.looseSchema = (opts) => opts as never +import type { FormOptions } from '@tanstack/form-core' export function createFormHook< const TFormComponents extends Record>, @@ -35,7 +32,7 @@ export function createFormHook< return { useFormContext: useFormContext as never, - appFormOptions, + appFormOptions: formOptions as never, defineAppFieldGroup: defineFieldGroup as never, useAppForm, } diff --git a/packages/vue-form/src/AppForm/createFormHook.public.ts b/packages/vue-form/src/AppForm/createFormHook.public.ts index 7b5b8d760..adb99974f 100644 --- a/packages/vue-form/src/AppForm/createFormHook.public.ts +++ b/packages/vue-form/src/AppForm/createFormHook.public.ts @@ -1,3 +1,4 @@ +import { formOptions } from '@tanstack/form-core' import { useInternalForm } from '../VueForm/VueFormApi.lib' import { defineFieldGroup } from '../FieldGroup/withFields.public' import { createAppFormInitializer } from './initializeAppForm.lib' @@ -8,11 +9,7 @@ import type { UseAppFormHook, } from './createFormHookTypes.public' import type { Component } from 'vue' -import type { FormOptions, FormOptionsApi } from '@tanstack/form-core' - -const appFormOptions = ((opts: unknown) => opts) as FormOptionsApi -appFormOptions.strictSchema = (opts) => opts as never -appFormOptions.looseSchema = (opts) => opts as never +import type { FormOptions } from '@tanstack/form-core' export function createFormHook< const TFormComponents extends Record, @@ -31,7 +28,7 @@ export function createFormHook< return { useFormContext: useFormContext as never, - appFormOptions, + appFormOptions: formOptions as never, defineAppFieldGroup: defineFieldGroup as never, useAppForm: useExtendedForm as never as UseAppFormHook<{ formComponents: TFormComponents