Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 37 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Values>({
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<string>({
validators: {
Expand All @@ -137,7 +169,7 @@ const form = createForm({
onChangeAsyncDebounceMs: 300,
},
}),
confirmEmail: field<string>({
confirmEmail: field<string, EmailFormValues>({
validators: {
onChangeListenTo: ['email'],
onChange: ({ value, values }) =>
Expand Down
17 changes: 11 additions & 6 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ const form = createForm({
password: field<string>(),
},
arrays: {
members: arrayField({
members: arrayField<{ id: string; name: string }>({
getItemId: (item) => item.id,
}),
},
Expand Down Expand Up @@ -324,13 +324,18 @@ Patches are for autosave, not for replacing TanStack state.

Public API should preserve typed values where practical:

- `createForm<TValues>()` infers `TValues` from `defaultValues`;
- `field<TValue>()` keeps control value type;
- `arrayField<TItem>()` keeps item type and id extractor type;
- `createForm(...)` infers `TValues` from `defaultValues`;
- configured field and array names are checked against TanStack deep paths;
- `field<TValue, TValues>()` types validator value/context when callbacks need
the full form value;
- `arrayField<TItem>()` 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

Expand Down
9 changes: 7 additions & 2 deletions docs/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Values>()`
and `arrayField<Values['members'][number]>()`. 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.
Expand Down
11 changes: 8 additions & 3 deletions src/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ type ArrayItemValue<TValue> =
? TItem
: never;

type NoExtraKeys<TValue, TAllowedKey extends PropertyKey> = Record<
Exclude<keyof TValue, TAllowedKey>,
never
>;

export type FieldConfigs<TValues extends object> = Partial<{
readonly [TName in FieldPath<TValues>]: FieldConfig<
FieldPathValue<TValues, TName>,
Expand Down Expand Up @@ -104,8 +109,8 @@ export type CreateFormOptions<
TArrays extends ArrayFieldConfigs<TValues> = Record<never, never>,
> = {
readonly defaultValues: TValues;
readonly fields: TFields;
readonly arrays?: TArrays;
readonly fields: TFields & NoExtraKeys<TFields, FieldPath<TValues>>;
readonly arrays?: TArrays & NoExtraKeys<TArrays, ArrayPath<TValues>>;
readonly validators?: FormValidators<TValues>;
};

Expand Down Expand Up @@ -563,7 +568,7 @@ class MobxForm<
continue;
}

this.#arrayConfigs.set(name, config as ArrayFieldConfig<unknown>);
this.#arrayConfigs.set(name, config);

const formArray = new MobxFormArray<
ArrayItemValue<ArrayPathValue<TValues, typeof name>>,
Expand Down
157 changes: 157 additions & 0 deletions test/types/form-types.test-d.ts
Original file line number Diff line number Diff line change
@@ -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<string, Values>({
validators: {
onChange: ({ value, values }) => {
value.toUpperCase();
values.password.toUpperCase();

return undefined;
},
onChangeListenTo: ['password'],
},
}),
'profile.age': field<number, Values>({
validators: {
onChange: ({ value, values }) => {
value.toFixed();
values.profile.firstName.toUpperCase();

return undefined;
},
},
}),
},
arrays: {
members: arrayField<Values['members'][number]>({
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<string>(),
},
});

createForm({
defaultValues: {
email: '',
password: '',
},
fields: {
email: field<string, { email: string; password: string }>({
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',
});