From 4fa80909868ccaa15d210dbf12d9b171e1a68892 Mon Sep 17 00:00:00 2001 From: Musa Toktas Date: Wed, 7 Oct 2026 10:34:50 +0400 Subject: [PATCH 1/2] fix(form-core): stop DeepKeys and DeepValue expansion on self-referencing types DeepKeysAndValuesImpl had no cycle or depth guard, so a type that contains itself (for example a recursive JSON type or an index signature that refers to its own type) was expanded until TypeScript reported TS2589. Track the array, tuple and object types on the current path and stop when one of them is reached again. The remaining path is typed as an unknown accessor, the same fallback already used for unknown values. Types without self-references produce the same keys and values as before. Fixes #1474 Fixes #1484 --- .changeset/calm-keys-stop.md | 5 + packages/form-core/src/util-types.ts | 53 ++++++++-- packages/form-core/tests/util-types.test-d.ts | 98 +++++++++++++++++++ 3 files changed, 149 insertions(+), 7 deletions(-) create mode 100644 .changeset/calm-keys-stop.md diff --git a/.changeset/calm-keys-stop.md b/.changeset/calm-keys-stop.md new file mode 100644 index 0000000000..c068582f5f --- /dev/null +++ b/.changeset/calm-keys-stop.md @@ -0,0 +1,5 @@ +--- +'@tanstack/form-core': patch +--- + +Fix `DeepKeys` and `DeepValue` failing with "Type instantiation is excessively deep and possibly infinite" (TS2589) for self-referencing types such as recursive JSON types. Expansion of a path now stops when a container type repeats on the path, and the remaining path is typed as an unknown accessor. Types without self-references are unchanged. diff --git a/packages/form-core/src/util-types.ts b/packages/form-core/src/util-types.ts index a27da3a92e..b1afbfa074 100644 --- a/packages/form-core/src/util-types.ts +++ b/packages/form-core/src/util-types.ts @@ -59,10 +59,12 @@ export type DeepKeyAndValueArray< TParent extends AnyDeepKeyAndValue, T extends ReadonlyArray, TAcc, + TVisited = never, > = DeepKeysAndValuesImpl< NonNullable, ArrayDeepKeyAndValue, - TAcc | ArrayDeepKeyAndValue + TAcc | ArrayDeepKeyAndValue, + TVisited | T > export type TupleAccessor< @@ -86,11 +88,13 @@ export type DeepKeyAndValueTuple< T extends ReadonlyArray, TAcc, TAllKeys extends AllTupleKeys = AllTupleKeys, + TVisited = never, > = TAllKeys extends any ? DeepKeysAndValuesImpl< NonNullable, TupleDeepKeyAndValue, - TAcc | TupleDeepKeyAndValue + TAcc | TupleDeepKeyAndValue, + TVisited | T > : never @@ -125,11 +129,13 @@ export type DeepKeyAndValueObject< T, TAcc, TAllKeys extends AllObjectKeys = AllObjectKeys, + TVisited = never, > = TAllKeys extends any ? DeepKeysAndValuesImpl< NonNullable, ObjectDeepKeyAndValue, - TAcc | ObjectDeepKeyAndValue + TAcc | ObjectDeepKeyAndValue, + TVisited | T > : never @@ -148,10 +154,33 @@ export type DeepKeysAndValues = ? DeepKeysAndValuesImpl : never +type IsIdentical = + (() => G extends A ? 1 : 2) extends () => G extends B ? 1 : 2 + ? true + : false + +/** + * @private + * Whether `T` is identical to one of the container types (arrays, tuples and + * objects) that are already being expanded further up the current path. + */ +type IsVisited = [TVisited] extends [never] + ? false + : true extends (TVisited extends any ? IsIdentical : never) + ? true + : false + +/** + * `TVisited` holds the container types on the path from the root to `T`. A + * type that contains itself (e.g. `type Json = Json[] | { [k: string]: Json }`) + * would otherwise be expanded forever, so when a container is reached again the + * recursion stops and the path continues as an unknown accessor. + */ export type DeepKeysAndValuesImpl< T, TParent extends AnyDeepKeyAndValue = never, TAcc = never, + TVisited = never, > = unknown extends T ? TAcc | UnknownDeepKeyAndValue : unknown extends T // this stops runaway recursion when T is any @@ -159,13 +188,23 @@ export type DeepKeysAndValuesImpl< : T extends string | number | boolean | bigint | Date ? TAcc : T extends ReadonlyArray - ? number extends T['length'] - ? DeepKeyAndValueArray - : DeepKeyAndValueTuple + ? true extends IsVisited + ? TAcc | UnknownDeepKeyAndValue + : number extends T['length'] + ? DeepKeyAndValueArray + : DeepKeyAndValueTuple, TVisited> : keyof T extends never ? TAcc | UnknownDeepKeyAndValue : T extends object - ? DeepKeyAndValueObject + ? true extends IsVisited + ? TAcc | UnknownDeepKeyAndValue + : DeepKeyAndValueObject< + TParent, + T, + TAcc, + AllObjectKeys, + TVisited + > : TAcc export type DeepRecord = { diff --git a/packages/form-core/tests/util-types.test-d.ts b/packages/form-core/tests/util-types.test-d.ts index 2df6827e1a..fc4931fe9c 100644 --- a/packages/form-core/tests/util-types.test-d.ts +++ b/packages/form-core/tests/util-types.test-d.ts @@ -520,3 +520,101 @@ describe('FieldsMap', () => { expectTypeOf().toBeNever() }) }) + +describe('self-referencing types', () => { + // https://github.com/TanStack/form/issues/1474 + type JsonData = + | string + | number + | boolean + | null + | JsonData[] + | { [key: string]: JsonData } + + type JsonForm = { name: string; data: JsonData } + + // https://github.com/TanStack/form/issues/1484 + type FieldValues = { + [key: string]: + | string + | string[] + | number + | boolean + | null + | FieldValues + | FieldValues[] + } + + it('should stop expanding a recursive union at the point where it repeats', () => { + type Keys = DeepKeys + + expectTypeOf<'name'>().toExtend() + expectTypeOf<'data'>().toExtend() + expectTypeOf<'data[0]'>().toExtend() + expectTypeOf<'data.foo'>().toExtend() + expectTypeOf<'data.foo[0].bar'>().toExtend() + expectTypeOf<'data[0].foo.bar[1]'>().toExtend() + expectTypeOf<'unknown'>().not.toExtend() + expectTypeOf<'name.foo'>().not.toExtend() + }) + + it('should stop expanding a recursive index signature at the point where it repeats', () => { + expectTypeOf>().toEqualTypeOf() + }) + + it('should resolve the value of a path through a recursive type', () => { + expectTypeOf>().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf() + expectTypeOf< + DeepValue + >().toEqualTypeOf() + expectTypeOf>().not.toBeNever() + }) + + it('should not treat a repeated non-recursive type as recursive', () => { + type Address = { street: string; geo: { lat: number; lng: number } } + type Shared = { + home: Address + work: Address + history: Address[] + pair: [Address, Address] + } + + expectTypeOf>().toEqualTypeOf< + | 'home' + | 'home.street' + | 'home.geo' + | 'home.geo.lat' + | 'home.geo.lng' + | 'work' + | 'work.street' + | 'work.geo' + | 'work.geo.lat' + | 'work.geo.lng' + | 'history' + | `history[${number}]` + | `history[${number}].street` + | `history[${number}].geo` + | `history[${number}].geo.lat` + | `history[${number}].geo.lng` + | 'pair' + | 'pair[0]' + | 'pair[0].street' + | 'pair[0].geo' + | 'pair[0].geo.lat' + | 'pair[0].geo.lng' + | 'pair[1]' + | 'pair[1].street' + | 'pair[1].geo' + | 'pair[1].geo.lat' + | 'pair[1].geo.lng' + >() + }) + + it('should keep optional-only objects nested in a similar object', () => { + type Weak = { a?: string; b?: { a?: string } } + + expectTypeOf>().toEqualTypeOf<'a' | 'b' | 'b.a'>() + }) +}) From 7b0684f36b66bd215ff5ff4a77654b79a7ebb1ad Mon Sep 17 00:00:00 2001 From: Musa Toktas Date: Wed, 7 Oct 2026 18:13:52 +0400 Subject: [PATCH 2/2] fix(form-core): accept consecutive array indexes below a recursive array When DeepKeysAndValuesImpl stops at a container type that repeats on the path, the path continued as an unknown accessor that only allows a `.${string}` suffix. For `type Json = Json[] | { [key: string]: Json }` this rejected valid paths such as `data[0][0]`. Use a dedicated accessor at the cut point that also accepts a bracket suffix. The generic unknown accessor and types without self-references are unchanged. --- .changeset/calm-keys-stop.md | 2 +- packages/form-core/src/util-types.ts | 26 +++++++++++++++++-- packages/form-core/tests/util-types.test-d.ts | 19 ++++++++++++++ 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/.changeset/calm-keys-stop.md b/.changeset/calm-keys-stop.md index c068582f5f..db85e1aa71 100644 --- a/.changeset/calm-keys-stop.md +++ b/.changeset/calm-keys-stop.md @@ -2,4 +2,4 @@ '@tanstack/form-core': patch --- -Fix `DeepKeys` and `DeepValue` failing with "Type instantiation is excessively deep and possibly infinite" (TS2589) for self-referencing types such as recursive JSON types. Expansion of a path now stops when a container type repeats on the path, and the remaining path is typed as an unknown accessor. Types without self-references are unchanged. +Fix `DeepKeys` and `DeepValue` failing with "Type instantiation is excessively deep and possibly infinite" (TS2589) for self-referencing types such as recursive JSON types. Expansion of a path now stops when a container type repeats on the path, and the remaining path is typed as an unknown accessor that also accepts consecutive array indexes such as `data[0][0]`. Types without self-references are unchanged. diff --git a/packages/form-core/src/util-types.ts b/packages/form-core/src/util-types.ts index b1afbfa074..2dae2ee084 100644 --- a/packages/form-core/src/util-types.ts +++ b/packages/form-core/src/util-types.ts @@ -149,6 +149,28 @@ export interface UnknownDeepKeyAndValue< value: unknown } +/** + * The accessor of a path below a point where a self-referencing type was cut + * off. Unlike `UnknownAccessor` it also accepts a bracket suffix, because the + * cut type can be an array that is directly nested in another array + * (e.g. `data[0][0]` for `type Json = Json[] | { [key: string]: Json }`). + */ +export type RecursiveAccessor = + TParent['key'] extends never + ? string + : + | `${TParent['key']}.${string}` + | `${TParent['key']}[${number}]` + | `${TParent['key']}[${number}].${string}` + | `${TParent['key']}[${number}][${string}` + +export interface RecursiveDeepKeyAndValue< + TParent extends AnyDeepKeyAndValue, +> extends AnyDeepKeyAndValue { + key: RecursiveAccessor + value: unknown +} + export type DeepKeysAndValues = DeepKeysAndValuesImpl extends AnyDeepKeyAndValue ? DeepKeysAndValuesImpl @@ -189,7 +211,7 @@ export type DeepKeysAndValuesImpl< ? TAcc : T extends ReadonlyArray ? true extends IsVisited - ? TAcc | UnknownDeepKeyAndValue + ? TAcc | RecursiveDeepKeyAndValue : number extends T['length'] ? DeepKeyAndValueArray : DeepKeyAndValueTuple, TVisited> @@ -197,7 +219,7 @@ export type DeepKeysAndValuesImpl< ? TAcc | UnknownDeepKeyAndValue : T extends object ? true extends IsVisited - ? TAcc | UnknownDeepKeyAndValue + ? TAcc | RecursiveDeepKeyAndValue : DeepKeyAndValueObject< TParent, T, diff --git a/packages/form-core/tests/util-types.test-d.ts b/packages/form-core/tests/util-types.test-d.ts index fc4931fe9c..40e176a221 100644 --- a/packages/form-core/tests/util-types.test-d.ts +++ b/packages/form-core/tests/util-types.test-d.ts @@ -558,6 +558,25 @@ describe('self-referencing types', () => { expectTypeOf<'name.foo'>().not.toExtend() }) + it('should accept consecutive array indexes below the point where a recursive array repeats', () => { + type Keys = DeepKeys + + expectTypeOf<'data[0][0]'>().toExtend() + expectTypeOf<'data[0][0][1]'>().toExtend() + expectTypeOf<'data[0][0].foo'>().toExtend() + expectTypeOf<'data[0][0].foo[1][2].bar'>().toExtend() + expectTypeOf<'data.foo[0][0]'>().toExtend() + expectTypeOf<'data[0].foo[0][1]'>().toExtend() + expectTypeOf<'data[0]foo'>().not.toExtend() + expectTypeOf<'data[foo]'>().not.toExtend() + expectTypeOf<'name[0]'>().not.toExtend() + }) + + it('should resolve the value of a path with consecutive array indexes', () => { + expectTypeOf>().not.toBeNever() + expectTypeOf>().not.toBeNever() + }) + it('should stop expanding a recursive index signature at the point where it repeats', () => { expectTypeOf>().toEqualTypeOf() })