diff --git a/.changeset/forty-eyes-fall.md b/.changeset/forty-eyes-fall.md index 332b624849..103c0c7d8d 100644 --- a/.changeset/forty-eyes-fall.md +++ b/.changeset/forty-eyes-fall.md @@ -9,8 +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`/`strictSchema` now error on missing schema +BREAKING: `formOptions.looseSchema` and `formOptions.strictSchema` now require a schema as +first parameter. This locks down inference to get the best type safety out of it vs. the options object alone. -Fix: `formOptions.looseSchema` now accepts optional `defaultValues` props +Fix: `formOptions.looseSchema` now allows `defaultValues` to omit properties instead of +requiring them to be explicitly undefined. diff --git a/docs/migrate-from-v1.md b/docs/migrate-from-v1.md index a03cbf6b08..3a57866d81 100644 --- a/docs/migrate-from-v1.md +++ b/docs/migrate-from-v1.md @@ -376,10 +376,11 @@ are `undefined`. Validators with literal `runOnSubmit: false` are skipped during submission, and their slots are typed as `undefined`. Use the parsed entry when an endpoint expects the schema's output type. -For schema-led option inference, `formOptions.strictSchema(...)` uses the -schema input as the form shape, while `formOptions.looseSchema(...)` permits -editable nullish defaults and combines them with the schema shape. These option -helpers only affect types; the validators still perform the runtime parsing. +For schema-led option inference, pass the schema first and the options second. +`formOptions.strictSchema(...)` uses the schema input as the form shape, while +`formOptions.looseSchema(...)` permits editable nullish defaults and combines +them with the schema shape. These option helpers only affect types; the +validators still perform the runtime parsing. When manually calling a schema, return `parseIssues(...)` with the failed issue array. In a field validator it produces field issues; in a form or group diff --git a/examples/react/next-server-actions-zod/src/app/shared-code.ts b/examples/react/next-server-actions-zod/src/app/shared-code.ts index b7f0871040..bb738b8bfd 100644 --- a/examples/react/next-server-actions-zod/src/app/shared-code.ts +++ b/examples/react/next-server-actions-zod/src/app/shared-code.ts @@ -9,7 +9,7 @@ export const serverSchema = z.object({ age: z.number().min(12), }) -export const formOpts = formOptions.strictSchema({ +export const formOpts = formOptions.strictSchema(clientSchema, { defaultValues: { age: 0, }, diff --git a/examples/react/ui-integration-shadcn/src/app/booking/shared-form.tsx b/examples/react/ui-integration-shadcn/src/app/booking/shared-form.tsx index 41381ee0f5..ea4bc1da35 100644 --- a/examples/react/ui-integration-shadcn/src/app/booking/shared-form.tsx +++ b/examples/react/ui-integration-shadcn/src/app/booking/shared-form.tsx @@ -38,43 +38,46 @@ export const rewardEarlyPunishLate = createValidator({ ], }) -export const bookingFormOptions = appFormOptions.strictSchema({ - errorVisibility: ({ state, fieldState }) => - fieldState.meta.isBlurred || state.submissionAttempts > 0, - validators: [rewardEarlyPunishLate(bookingFormSchema)], - defaultValues: { - guestDetails: { - name: '', - email: '', - phoneNumber: '', - guestCount: 1, - }, - stayDates: { - dateRange: { - from: undefined, - to: undefined, +export const bookingFormOptions = appFormOptions.strictSchema( + bookingFormSchema, + { + errorVisibility: ({ state, fieldState }) => + fieldState.meta.isBlurred || state.submissionAttempts > 0, + validators: [rewardEarlyPunishLate(bookingFormSchema)], + defaultValues: { + guestDetails: { + name: '', + email: '', + phoneNumber: '', + guestCount: 1, + }, + stayDates: { + dateRange: { + from: undefined, + to: undefined, + }, + arrivalTime: '', + }, + roomPreferences: { + roomType: 'standard', + bedPreference: 'queen', + smokingPreference: 'non-smoking', + floorPreference: 'no-preference', + }, + budget: { + maxNightlyBudget: 200, + currency: 'USD', + }, + addOns: { + includeBreakfast: false, + airportPickup: false, + parkingRequired: false, + }, + specialRequests: { + notes: '', }, - arrivalTime: '', - }, - roomPreferences: { - roomType: 'standard', - bedPreference: 'queen', - smokingPreference: 'non-smoking', - floorPreference: 'no-preference', - }, - budget: { - maxNightlyBudget: 200, - currency: 'USD', - }, - addOns: { - includeBreakfast: false, - airportPickup: false, - parkingRequired: false, - }, - specialRequests: { - notes: '', }, }, -}) +) export type BookingForm = ReactFormType diff --git a/packages/form-core/src/utils.public.ts b/packages/form-core/src/utils.public.ts index 22ccdc1c07..0d7ede1740 100644 --- a/packages/form-core/src/utils.public.ts +++ b/packages/form-core/src/utils.public.ts @@ -51,157 +51,143 @@ export type FormValidatorData> = export type NullableSchemaData> = Editable> -type FormValidatorsWithStandardSchema< - TFormValidators extends FormValidators, -> = - Extract< - TFormValidators[number], - { readonly run: StandardSchemaV1 } - > extends never - ? never - : TFormValidators +type StandardSchemaInput> = + TSchema extends StandardSchemaV1 ? TInput : never -/** - * 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, +type LooseSchemaFormOptions< + TSchemaInput, + TFormData extends Editable, + TFormValidators extends FormValidators< + NoInfer> + >, TSubmitReturn, - TComponents, -> = FormOptions & { - validators: FormValidatorsWithStandardSchema +> = 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. + * Types strict form options using a separate schema as the source of the form + * data type. * - * 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. + * 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. * - * 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. - * - * @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. + * 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 profileOptions = formOptions.strictSchema({ + * const profileSchema = z.object({ name: z.string().min(1) }) + * const profileOptions = formOptions.strictSchema(profileSchema, { * defaultValues: { name: '' }, * validators: [ + * { triggers: ['change'], run: profileSchema }, * { * triggers: ['change'], - * run: z.object({ name: z.string().min(1) }), + * run: ({ value }) => + * value.name.length === 0 ? 'Name is required' : undefined, * }, * ], - * onSubmit: ({ schemaOutputs }) => saveProfile(schemaOutputs[0]), * }) * ``` * + * @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's input shape. + * schema input as its form data type. + * @typeParam TComponents - Library-managed. Do not specify explicitly. + * @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. - * @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, + const TSchema extends StandardSchemaV1, + const TFormValidators extends FormValidators>, TSubmitReturn, >( - options: StandardSchemaFormOptions< - TFormData, + schema: TSchema, + options: FormOptions< + StandardSchemaInput, TFormValidators, TSubmitReturn, unknown >, ) => FormOptions< - FormValidatorData, + StandardSchemaInput, 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. + * Types loose schema form options using a separate schema as the source of the + * final valid form shape. * - * At runtime, this returns the original options object and does not run the - * schema. + * `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. * - * `validators` must contain at least one Standard Schema 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. + * 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 bookingOptions = formOptions.looseSchema({ + * const bookingSchema = z.object({ startDate: z.date() }) + * const bookingOptions = formOptions.looseSchema(bookingSchema, { * defaultValues: { startDate: null }, * validators: [ + * { triggers: ['blur'], run: bookingSchema }, * { - * triggers: ['blur'], - * run: z.object({ startDate: z.date() }), + * triggers: ['change'], + * run: ({ value }) => + * value.startDate === null ? 'Choose a date' : undefined, * }, * ], - * 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. + * @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 TComponents - Library-managed. Do not specify explicitly. + * @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. - * @typeParam TComponents - Library-managed. Do not specify explicitly. - * */ export type FormOptionsLooseSchemaFn = < - const TFormValidators extends FormValidators, - const TFormData extends NullableSchemaData, + const TSchema extends StandardSchemaV1, + const TFormData extends Editable>, + const TFormValidators extends FormValidators< + NoInfer>> + >, TSubmitReturn, >( - options: StandardSchemaFormOptions< + schema: TSchema, + options: LooseSchemaFormOptions< + StandardSchemaInput, TFormData, TFormValidators, - TSubmitReturn, - unknown + TSubmitReturn >, ) => FormOptions< - InferUnion>, + InferUnion>, TFormValidators, TSubmitReturn, TComponents @@ -253,25 +239,31 @@ 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. - * - * `validators` must contain at least one Standard Schema to provide the type - * inference and perform validation. + * Pass the schema as the first argument and the options as the second. 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. * * @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 - * 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]), @@ -280,6 +272,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. @@ -296,25 +289,32 @@ 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. - * - * `validators` must contain at least one Standard Schema to provide the type - * inference and perform validation. + * Pass the schema as the first argument and the options as the second. + * `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. * * @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 - * 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]), @@ -324,6 +324,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. @@ -369,11 +370,9 @@ 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 = ((_schema: unknown, opts: unknown) => opts) as never +formOptions.looseSchema = ((_schema: unknown, opts: unknown) => opts) 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 34273b0e57..be414c1ecf 100644 --- a/packages/form-core/tests/validation-public.test.ts +++ b/packages/form-core/tests/validation-public.test.ts @@ -12,19 +12,20 @@ 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, }, ], } 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 eeeaa828cc..dfc5c409a7 100644 --- a/packages/form-core/tests/validation.test-d.ts +++ b/packages/form-core/tests/validation.test-d.ts @@ -98,11 +98,12 @@ describe('formOptions', () => { }) it('infers form data from a strict schema', () => { - const options = formOptions.strictSchema({ + const schema = z.object({ name: z.string() }) + const options = formOptions.strictSchema(schema, { defaultValues: { name: '' }, validators: [ { - run: z.object({ name: z.string() }), + run: schema, triggers: ['change'], }, ], @@ -111,17 +112,133 @@ 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({ + it('infers schema input and output from schema-only validators', () => { + const schema = z.object({ age: z.string().transform(Number) }) + const options = formOptions.strictSchema(schema, { + 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() + }, }) - void options + 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({ + 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, @@ -129,13 +246,7 @@ describe('formOptions', () => { }, validators: [ { - run: z.object({ - name: z.string(), - address: z.object({ - city: z.string(), - postcode: z.number(), - }), - }), + run: schema, triggers: ['change'], }, ], @@ -150,15 +261,116 @@ describe('formOptions', () => { }>() }) - it('rejects loose schema options without a schema', () => { - // @ts-expect-error Schema modes require a Standard Schema validator. - 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, + }, + }) }) }) + describe('ErrorVisibility', () => { it('types callback scoped and pre-visibility field state', () => { const options: FormOptions< @@ -241,11 +453,12 @@ describe('ErrorVisibility', () => { const showErrorsAfterSubmit = createErrorVisibility( ({ state }) => state.submissionAttempts > 0, ) - const options = formOptions.strictSchema({ + const schema = z.object({ name: z.string() }) + const options = formOptions.strictSchema(schema, { defaultValues: { name: '' }, validators: [ { - run: z.object({ name: z.string() }), + run: schema, triggers: ['change'], }, ], diff --git a/packages/preact-form/src/AppForm/createFormHook.public.ts b/packages/preact-form/src/AppForm/createFormHook.public.ts index 7c984fd78a..038324b03e 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 1ffd2c15db..a842120da5 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 bd9cb236b0..dda81ff882 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 c14034ca03..a66d1cf823 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 248a3907bf..6f9ff759ec 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 7b5b8d760f..adb99974f7 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