diff --git a/.changeset/forty-eyes-fall.md b/.changeset/forty-eyes-fall.md index 332b62484..7fc9c302d 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 +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 accepts optional `defaultValues` props +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 22ccdc1c0..2a4f64ae9 100644 --- a/packages/form-core/src/utils.public.ts +++ b/packages/form-core/src/utils.public.ts @@ -51,161 +51,253 @@ 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. + * 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. - * - * 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. - * - * @example - * ```ts - * const profileOptions = formOptions.strictSchema({ - * defaultValues: { name: '' }, - * validators: [ - * { - * triggers: ['change'], - * run: z.object({ name: z.string().min(1) }), - * }, - * ], - * onSubmit: ({ schemaOutputs }) => saveProfile(schemaOutputs[0]), - * }) - * ``` + * 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. * - * @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: StandardSchemaFormOptions< - TFormData, +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, - unknown - >, -) => FormOptions< - FormValidatorData, - TFormValidators, - TSubmitReturn, - TComponents -> + 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. - * - * 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 overloads used to type loose schema form options. * - * @example - * ```ts - * const bookingOptions = formOptions.looseSchema({ - * defaultValues: { startDate: null }, - * validators: [ - * { - * triggers: ['blur'], - * run: z.object({ startDate: z.date() }), - * }, - * ], - * onSubmit: ({ schemaOutputs }) => saveBooking(schemaOutputs[0]), - * }) - * ``` + * 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. * - * @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: StandardSchemaFormOptions< - TFormData, +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, - unknown - >, -) => FormOptions< - InferUnion>, - TFormValidators, - TSubmitReturn, - TComponents -> + 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 @@ -253,25 +345,33 @@ 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 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 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 +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. @@ -296,25 +397,33 @@ 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 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 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 +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. @@ -369,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 eeeaa828c..f3cb68524 100644 --- a/packages/form-core/tests/validation.test-d.ts +++ b/packages/form-core/tests/validation.test-d.ts @@ -111,13 +111,122 @@ 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. + 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() + }, }) - 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', () => { @@ -150,15 +259,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< 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