From e82ebbccc58f5388885f135cf3e4189cf9706f4c Mon Sep 17 00:00:00 2001 From: Sasha Drapkin Date: Fri, 11 Sep 2026 11:56:21 +0300 Subject: [PATCH] fix(form-core): keep `undefined` out of the field error array type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `FieldLikeMetaDerived['errors']` unions `UnwrapOneLevelOfArray<...>` over all nine validator slots. Every slot without a validator resolves to `undefined`, and `UnwrapOneLevelOfArray` is `undefined`, so each unused slot leaked `undefined` into the element type. With a single form-level Standard Schema — eight slots unused — the array inferred as `(StandardSchemaV1Issue | undefined)[]`, which cannot be iterated without a guard or a cast. The value is already filtered at runtime, and the form-level `errors` types already wrap their union in `NonNullable`. Wrap the field-level union to match. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/tidy-errors-narrow.md | 5 ++ packages/form-core/src/types.ts | 70 +++++++++++-------- packages/form-core/tests/FieldApi.test-d.ts | 16 ++--- .../form-core/tests/FormGroupApi.test-d.ts | 4 +- .../tests/standardSchemaValidator.test-d.ts | 54 ++++++++++++++ packages/preact-form/tests/useField.test.tsx | 2 +- .../preact-form/tests/useFormGroup.test-d.tsx | 2 +- packages/react-form/tests/useField.test.tsx | 2 +- .../react-form/tests/useFormGroup.test-d.tsx | 2 +- .../tests/createFormGroup.test-d.tsx | 2 +- 10 files changed, 109 insertions(+), 50 deletions(-) create mode 100644 .changeset/tidy-errors-narrow.md diff --git a/.changeset/tidy-errors-narrow.md b/.changeset/tidy-errors-narrow.md new file mode 100644 index 0000000000..cb32ef8913 --- /dev/null +++ b/.changeset/tidy-errors-narrow.md @@ -0,0 +1,5 @@ +--- +'@tanstack/form-core': patch +--- + +Remove the spurious `undefined` from the element type of `field.state.meta.errors`. Each unused validator slot previously contributed `undefined` to the union, so a single form-level Standard Schema produced `(StandardSchemaV1Issue | undefined)[]` and the array could not be iterated without a guard or a cast. The array is already filtered at runtime, and the form-level `errors` type is already wrapped in `NonNullable`; this aligns the field-level type with both. diff --git a/packages/form-core/src/types.ts b/packages/form-core/src/types.ts index b3c90a8011..d986d5246b 100644 --- a/packages/form-core/src/types.ts +++ b/packages/form-core/src/types.ts @@ -658,37 +658,47 @@ export type FieldLikeMetaDerived< * An array of errors related to the field value. */ errors: Array< - | UnwrapOneLevelOfArray< - UnwrapFieldValidateOrFn - > - | UnwrapOneLevelOfArray< - UnwrapFieldValidateOrFn - > - | UnwrapOneLevelOfArray< - UnwrapFieldAsyncValidateOrFn - > - | UnwrapOneLevelOfArray< - UnwrapFieldValidateOrFn - > - | UnwrapOneLevelOfArray< - UnwrapFieldAsyncValidateOrFn - > - | UnwrapOneLevelOfArray< - UnwrapFieldValidateOrFn - > - | UnwrapOneLevelOfArray< - UnwrapFieldAsyncValidateOrFn - > - | UnwrapOneLevelOfArray< - UnwrapFieldValidateOrFn - > - | UnwrapOneLevelOfArray< - UnwrapFieldAsyncValidateOrFn< - TName, - TOnDynamicAsync, - TFormOnDynamicAsync + NonNullable< + | UnwrapOneLevelOfArray< + UnwrapFieldValidateOrFn > - > + | UnwrapOneLevelOfArray< + UnwrapFieldValidateOrFn + > + | UnwrapOneLevelOfArray< + UnwrapFieldAsyncValidateOrFn< + TName, + TOnChangeAsync, + TFormOnChangeAsync + > + > + | UnwrapOneLevelOfArray< + UnwrapFieldValidateOrFn + > + | UnwrapOneLevelOfArray< + UnwrapFieldAsyncValidateOrFn + > + | UnwrapOneLevelOfArray< + UnwrapFieldValidateOrFn + > + | UnwrapOneLevelOfArray< + UnwrapFieldAsyncValidateOrFn< + TName, + TOnSubmitAsync, + TFormOnSubmitAsync + > + > + | UnwrapOneLevelOfArray< + UnwrapFieldValidateOrFn + > + | UnwrapOneLevelOfArray< + UnwrapFieldAsyncValidateOrFn< + TName, + TOnDynamicAsync, + TFormOnDynamicAsync + > + > + > > /** * A flag that is `true` if the field's value has not been modified by the user. Opposite of `isDirty`. diff --git a/packages/form-core/tests/FieldApi.test-d.ts b/packages/form-core/tests/FieldApi.test-d.ts index 01f3e136b6..5a6426d55e 100644 --- a/packages/form-core/tests/FieldApi.test-d.ts +++ b/packages/form-core/tests/FieldApi.test-d.ts @@ -221,9 +221,7 @@ it('should have the correct types returned from field validators in array', () = }, }) - expectTypeOf(field.state.meta.errors).toEqualTypeOf< - Array<'123' | undefined> - >() + expectTypeOf(field.state.meta.errors).toEqualTypeOf>() }) it('should have the correct types returned from form validators in array', () => { @@ -288,9 +286,7 @@ it('should handle "fields" return types added to the field\'s error array itself name: 'firstName', }) - expectTypeOf(field.getMeta().errors).toEqualTypeOf< - Array<'Testing' | undefined> - >() + expectTypeOf(field.getMeta().errors).toEqualTypeOf>() }) it('should handle "fields" async return types added to the field\'s errorMap itself', () => { @@ -340,9 +336,7 @@ it('should handle "fields" async return types added to the field\'s error array name: 'firstName', }) - expectTypeOf(field.getMeta().errors).toEqualTypeOf< - Array<'Testing' | undefined> - >() + expectTypeOf(field.getMeta().errors).toEqualTypeOf>() }) it('should handle "sub-fields" async return types added to the field\'s error array itself', () => { @@ -368,9 +362,7 @@ it('should handle "sub-fields" async return types added to the field\'s error ar name: 'person.firstName', }) - expectTypeOf(field.getMeta().errors).toEqualTypeOf< - Array<'Testing' | undefined> - >() + expectTypeOf(field.getMeta().errors).toEqualTypeOf>() }) it('should only have field-level error types returned from parseValueWithSchema and parseValueWithSchemaAsync', () => { diff --git a/packages/form-core/tests/FormGroupApi.test-d.ts b/packages/form-core/tests/FormGroupApi.test-d.ts index 7d6871a671..8cf942be86 100644 --- a/packages/form-core/tests/FormGroupApi.test-d.ts +++ b/packages/form-core/tests/FormGroupApi.test-d.ts @@ -116,9 +116,7 @@ it('should type errors array from group validators', () => { }, }) - expectTypeOf(group.state.meta.errors).toEqualTypeOf< - Array<'change-error' | undefined> - >() + expectTypeOf(group.state.meta.errors).toEqualTypeOf>() }) it('should type handleSubmit return as Promise', () => { diff --git a/packages/form-core/tests/standardSchemaValidator.test-d.ts b/packages/form-core/tests/standardSchemaValidator.test-d.ts index 9b32cc1059..17b38b3c5e 100644 --- a/packages/form-core/tests/standardSchemaValidator.test-d.ts +++ b/packages/form-core/tests/standardSchemaValidator.test-d.ts @@ -119,4 +119,58 @@ describe('standard schema validator', () => { Promise >() }) + + it("Should not add `undefined` to the field's error array when only a form-level schema is present", () => { + const form = new FormApi({ + defaultValues: { + firstName: '', + }, + validators: { + onChange: z.object({ + firstName: z.string().min(1, 'Testing'), + }), + }, + }) + + const field = new FieldApi({ + form, + name: 'firstName', + }) + + // The unused validator slots must not leak `undefined` into the element + // union; the errors array is filtered before it reaches field meta. + expectTypeOf(field.getMeta().errors).toEqualTypeOf< + Array + >() + + // Consequently the issues are iterable without a guard or a cast. + expectTypeOf( + field.getMeta().errors.map((issue) => issue.message), + ).toEqualTypeOf>() + }) + + it("Should keep `undefined` out of the error array while preserving a field validator's own return type", () => { + const form = new FormApi({ + defaultValues: { + firstName: '', + }, + validators: { + onDynamic: z.object({ + firstName: z.string().min(1, 'Testing'), + }), + }, + }) + + const field = new FieldApi({ + form, + name: 'firstName', + validators: { + onDynamic: ({ value }) => (value ? undefined : ('Required' as const)), + }, + }) + + expectTypeOf(field.getMeta().errors).toEqualTypeOf< + Array + >() + }) }) diff --git a/packages/preact-form/tests/useField.test.tsx b/packages/preact-form/tests/useField.test.tsx index 28632faa64..0547bd1890 100644 --- a/packages/preact-form/tests/useField.test.tsx +++ b/packages/preact-form/tests/useField.test.tsx @@ -747,7 +747,7 @@ describe('useField', () => { /> {field.state.meta.errors.map((err) => { - return
{err}
+ return
{err}
})} ) diff --git a/packages/preact-form/tests/useFormGroup.test-d.tsx b/packages/preact-form/tests/useFormGroup.test-d.tsx index 765ca13784..aa16f3a2ad 100644 --- a/packages/preact-form/tests/useFormGroup.test-d.tsx +++ b/packages/preact-form/tests/useFormGroup.test-d.tsx @@ -301,7 +301,7 @@ describe('useFormGroup field-like meta surface', () => { }) expectTypeOf(group.state.meta.errors).toEqualTypeOf< - Array<'change-error' | undefined> + Array<'change-error'> >() } }) diff --git a/packages/react-form/tests/useField.test.tsx b/packages/react-form/tests/useField.test.tsx index 4454165947..84fecda4b8 100644 --- a/packages/react-form/tests/useField.test.tsx +++ b/packages/react-form/tests/useField.test.tsx @@ -1093,7 +1093,7 @@ describe('useField', () => { /> {field.state.meta.errors.map((err) => { - return
{err}
+ return
{err}
})} ) diff --git a/packages/react-form/tests/useFormGroup.test-d.tsx b/packages/react-form/tests/useFormGroup.test-d.tsx index 765ca13784..aa16f3a2ad 100644 --- a/packages/react-form/tests/useFormGroup.test-d.tsx +++ b/packages/react-form/tests/useFormGroup.test-d.tsx @@ -301,7 +301,7 @@ describe('useFormGroup field-like meta surface', () => { }) expectTypeOf(group.state.meta.errors).toEqualTypeOf< - Array<'change-error' | undefined> + Array<'change-error'> >() } }) diff --git a/packages/solid-form/tests/createFormGroup.test-d.tsx b/packages/solid-form/tests/createFormGroup.test-d.tsx index 4c0eb99bf0..4074be2cc3 100644 --- a/packages/solid-form/tests/createFormGroup.test-d.tsx +++ b/packages/solid-form/tests/createFormGroup.test-d.tsx @@ -301,7 +301,7 @@ describe('createFormGroup field-like meta surface', () => { })) expectTypeOf(group().state.meta.errors).toEqualTypeOf< - Array<'change-error' | undefined> + Array<'change-error'> >() } })