diff --git a/README.md b/README.md index cfdde42..84fd173 100644 --- a/README.md +++ b/README.md @@ -115,17 +115,49 @@ form.dispose(); Exports include `createForm`, `field`, `arrayField`, and public types for form options, controls, arrays, validators, patches, and listeners. +## TypeScript Contract + +`createForm(...)` infers the form value shape from `defaultValues`. Created +controls, arrays, `reset(values)`, and `applyServerErrors(paths)` are typed +from that value shape and the configured field/array paths. + +Use explicit helper generics when a callback needs contextual types inside the +configuration object: + +```ts +type Values = { email: string; confirmEmail: string }; + +field({ + validators: { + onChange: ({ value, values }) => + value === values.confirmEmail ? undefined : 'Emails must match', + onChangeListenTo: ['confirmEmail'], + }, +}); + +arrayField<{ id: string; name: string }>({ + getItemId: (item) => item.id, +}); +``` + ## Validation Field validators support sync, async, debounce, submit, blur, and linked-field validation where TanStack Form Core supports the underlying behavior. ```ts +type EmailFormValues = { + email: string; + confirmEmail: string; +}; + +const defaultValues: EmailFormValues = { + email: '', + confirmEmail: '', +}; + const form = createForm({ - defaultValues: { - email: '', - confirmEmail: '', - }, + defaultValues, fields: { email: field({ validators: { @@ -137,7 +169,7 @@ const form = createForm({ onChangeAsyncDebounceMs: 300, }, }), - confirmEmail: field({ + confirmEmail: field({ validators: { onChangeListenTo: ['email'], onChange: ({ value, values }) => diff --git a/docs/architecture.md b/docs/architecture.md index e121b25..2187fb7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -92,7 +92,7 @@ const form = createForm({ password: field(), }, arrays: { - members: arrayField({ + members: arrayField<{ id: string; name: string }>({ getItemId: (item) => item.id, }), }, @@ -324,13 +324,18 @@ Patches are for autosave, not for replacing TanStack state. Public API should preserve typed values where practical: -- `createForm()` infers `TValues` from `defaultValues`; -- `field()` keeps control value type; -- `arrayField()` keeps item type and id extractor type; +- `createForm(...)` infers `TValues` from `defaultValues`; +- configured field and array names are checked against TanStack deep paths; +- `field()` types validator value/context when callbacks need + the full form value; +- `arrayField()` types the id extractor and public item operations; +- created controls, arrays, `reset(values)`, and `applyServerErrors(paths)` stay + typed from `defaultValues` and configured paths; - deep path typing should not leak complex implementation generics. -If deep path typing becomes too complex, isolate casts in internal helpers and -keep the public API readable. +Type tests live in `test/types/*.test-d.ts` and are compiled by +`npm run tsc`. If deeper contextual typing becomes too complex, isolate casts +in internal helpers and keep the public API readable. ## Disposal diff --git a/docs/limitations.md b/docs/limitations.md index be32d11..318144f 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -13,8 +13,13 @@ implementation PR confirms or changes a limitation. ## Initial Limitations To Validate During Implementation -- Deep path typing may be limited. Prefer a clean public API with internal casts - over leaking heavy TanStack generics. +- Post-creation form values, controls, reset values, server-error paths, and array + item operations are type-checked from `defaultValues` and configured field/array + paths. Validator callback context and `arrayField({ getItemId })` item context + currently need explicit helper generics, for example `field()` + and `arrayField()`. This keeps TanStack's heavy + generics out of the public form object while leaving room for a future + contextual builder API. - Array server-error reindexing is implemented for `arrayField` wrappers with stable ids. Future low-level array path APIs without `getItemId` would need a separate path-index policy. diff --git a/src/form.ts b/src/form.ts index 114cd7a..5dbe2a3 100644 --- a/src/form.ts +++ b/src/form.ts @@ -57,6 +57,11 @@ type ArrayItemValue = ? TItem : never; +type NoExtraKeys = Record< + Exclude, + never +>; + export type FieldConfigs = Partial<{ readonly [TName in FieldPath]: FieldConfig< FieldPathValue, @@ -104,8 +109,8 @@ export type CreateFormOptions< TArrays extends ArrayFieldConfigs = Record, > = { readonly defaultValues: TValues; - readonly fields: TFields; - readonly arrays?: TArrays; + readonly fields: TFields & NoExtraKeys>; + readonly arrays?: TArrays & NoExtraKeys>; readonly validators?: FormValidators; }; @@ -563,7 +568,7 @@ class MobxForm< continue; } - this.#arrayConfigs.set(name, config as ArrayFieldConfig); + this.#arrayConfigs.set(name, config); const formArray = new MobxFormArray< ArrayItemValue>, diff --git a/test/types/form-types.test-d.ts b/test/types/form-types.test-d.ts new file mode 100644 index 0000000..7842387 --- /dev/null +++ b/test/types/form-types.test-d.ts @@ -0,0 +1,157 @@ +import { + arrayField, + createForm, + field, + type FormPatch, +} from '../../src/index.js'; + +type Values = { + email: string; + password: string; + profile: { + firstName: string; + age: number; + }; + members: Array<{ + id: string; + name: string; + active: boolean; + }>; +}; + +const defaultValues: Values = { + email: '', + password: '', + profile: { + firstName: '', + age: 0, + }, + members: [{ id: '1', name: '', active: false }], +}; + +const form = createForm({ + defaultValues, + fields: { + email: field({ + validators: { + onChange: ({ value, values }) => { + value.toUpperCase(); + values.password.toUpperCase(); + + return undefined; + }, + onChangeListenTo: ['password'], + }, + }), + 'profile.age': field({ + validators: { + onChange: ({ value, values }) => { + value.toFixed(); + values.profile.firstName.toUpperCase(); + + return undefined; + }, + }, + }), + }, + arrays: { + members: arrayField({ + getItemId: (item) => item.id, + }), + }, +}); + +form.controls.email.setValue('user@example.com'); +form.controls['profile.age'].setValue(42); +form.arrays.members.push({ id: '2', name: 'Ada', active: true }); +form.arrays.members.items[0]?.controls.name.setValue('Grace'); +form.reset({ + email: '', + password: '', + profile: { + firstName: '', + age: 1, + }, + members: [], +}); +form.applyServerErrors({ + email: 'Invalid', + 'profile.age': 'Required', + 'members[0].name': 'Required', +}); +form.onPatch((patches) => { + const patch: FormPatch | undefined = patches[0]; + void patch; +}); + +const rawValue: Values = form.getRawValue(); +const emailValue: string = form.controls.email.value; +const ageValue: number = form.controls['profile.age'].value; +const memberValue: Values['members'][number] | undefined = + form.arrays.members.items[0]?.value; +void rawValue; +void emailValue; +void ageValue; +void memberValue; + +createForm({ + defaultValues: { + email: '', + }, + fields: { + // @ts-expect-error unknown field paths must be rejected + missing: field(), + }, +}); + +createForm({ + defaultValues: { + members: [{ id: '1', name: '' }], + }, + fields: {}, + arrays: { + // @ts-expect-error unknown array paths must be rejected + missing: arrayField({ getItemId: (item) => item.id }), + }, +}); + +createForm({ + defaultValues: { + email: '', + count: 0, + }, + fields: { + // @ts-expect-error field config value type must match defaultValues path + count: field(), + }, +}); + +createForm({ + defaultValues: { + email: '', + password: '', + }, + fields: { + email: field({ + validators: { + // @ts-expect-error linked field paths must exist + onChangeListenTo: ['missing'], + }, + }), + }, +}); + +// @ts-expect-error control setValue must preserve field value type +form.controls.email.setValue(123); +// @ts-expect-error nested control setValue must preserve nested value type +form.controls['profile.age'].setValue('42'); +// @ts-expect-error arrays must preserve item shape +form.arrays.members.push({ id: '3', title: 'Ada' }); +// @ts-expect-error array item controls must preserve item field value type +form.arrays.members.items[0]?.controls.active.setValue('yes'); +// @ts-expect-error reset must preserve form value shape +form.reset({ email: '' }); +form.applyServerErrors({ + // @ts-expect-error server error paths must exist + missing: 'Invalid', +});