diff --git a/.changeset/calm-keys-stop.md b/.changeset/calm-keys-stop.md new file mode 100644 index 0000000000..db85e1aa71 --- /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 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 a27da3a92e..2dae2ee084 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 @@ -143,15 +149,60 @@ 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 : 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 +210,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 | RecursiveDeepKeyAndValue + : number extends T['length'] + ? DeepKeyAndValueArray + : DeepKeyAndValueTuple, TVisited> : keyof T extends never ? TAcc | UnknownDeepKeyAndValue : T extends object - ? DeepKeyAndValueObject + ? true extends IsVisited + ? TAcc | RecursiveDeepKeyAndValue + : 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..40e176a221 100644 --- a/packages/form-core/tests/util-types.test-d.ts +++ b/packages/form-core/tests/util-types.test-d.ts @@ -520,3 +520,120 @@ 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 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() + }) + + 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'>() + }) +})