Check this typescript 4.2 snippet I found somewhere (playground here):
type BlackMagic<T> = { [K in keyof T]: BlackMagic<T[K]> }
declare const foo: BlackMagic<{q: string}>;
declare const str: BlackMagic<string>;
declare const num: BlackMagic<12>;
I can't wrap my head around it. How does TS handle this? How is it not stuck in an infinite recursion? Specifically, in the case of str
and num
, hovering over the variables shows that TS is resolving the types to just string
and 12
. How's that even happening?
Infinite Recursion occurs when the recursion does not terminate after a finite number of recursive calls. As the base condition is never met, the recursion carries on infinitely.
To prevent infinite recursion, you need at least one branch (i.e. of an if/else statement) that does not make a recursive call. Branches without recursive calls are called base cases; branches with recursive calls are called recursive cases. Functions can also be mutually recursive.
If a recursion never reaches a base case, it will go on making recursive calls forever and the program will never terminate. This is known as infinite recursion, and it is generally not considered a good idea. In most programming environments, a program with an infinite recursion will not really run forever.
Iteration and recursion can occur infinitely: An infinite loop occurs with iteration if the loop-continuation test never becomes false; infinite recursion occurs if the recursion step does not reduce the problem in a manner that converges on the base case.
You would get stuck in an infinite recursion if your type included BlackMagic<T>
with the same T
, but here we are applying the utility type BlackMagic
to a different value T[K]
.
type BlackMagic<T> = { [K in keyof T]: BlackMagic<T[K]> }
This type says that BlackMagic<T>
is an object
where the keys are the keys of T
and the values are a BlackMagic
mapped version of the values of T
.
The BlackMagic
type doesn't actually do anything in your examples your examples str
and num
.
If T
is a primitive type instead of an object
then BlackMagic<T>
is just T
itself. This is the standard behavior for utility types which are mapped types. For example Partial<12>
is just 12
. This behavior is explained in the FAQ Common "Bugs" That Aren't Bugs
Mapped types declared as
{ [ K in keyof T ]: U }
whereT
is a type parameter are known as homomorphic mapped types, which means that the mapped type is a structure preserving function ofT
. When type parameterT
is instantiated with a primitive type the mapped type evaluates to the same primitive.
foo: BlackMagic<{q: string}>
will actually do some mapping, but it's only one level deep. BlackMagic<{q: string}>
becomes {q: BlackMagic<string>}
. We just saw that BlackMagic<string>
is string
, so the type next becomes {q: string}
.
As Linda explains, the reason why there's no infinite recursion is that when a homomorphic mapped type is applied to a primitive type it evaluates to that primitive type. Still, it's interesting to see what happens with a non-homomorphic mapped type. To define a non-homomorphic type, you can declare an identity type I
such that I<'key1' | 'key2'> = 'key1' | 'key2'
, using a conditional type:
type I<T> = T extends infer S ? S : T
By taking the keys from I<keyof T>
instead of keyof T
, you can define a simple non-recursive and non-homomorphic type:
type NonHomomorphicMap<T> = {[K in I<keyof T>]: 42}
and apply it to a bunch of other types:
type TestObject = NonHomomorphicMap<{q: string}> // {q: 42}
type TestString = NonHomomorphicMap<string> // {[x: number]: 42, toString: 42, charAt: 42, ...}
type TestNum = NonHomomorphicMap<12> // {toString: 42, toFixed: 42, toExponential: 42, toPrecision: 42, valueOf: 42, toLocaleString: 42}
type TestFun = NonHomomorphicMap<() => number> // {}
For string
and 12
the type now evaluates to an object type with keys for all the methods on string
and number
, and an [x: number]
index signature for string
.
Interestingly, functions are not treated as primitive types, but as objects without keys, so NonHomomorphicMap<() => any>
equals {}
. This also happens with homomorphic mapped types (i.e. BlackMagic<() => any>
equals {}
), so BlackMagic
is not identity.
It's also possible to make a recursive non-homomorphic type similar to BlackMagic
, but to see the full type on hover you need a Normalize
type that fully evaluates all recursive occurrences:
type Normalize<T> =
T extends Function
? T
: T extends infer S ? {[K in keyof S]: S[K]} : never
Now a non-homomorphic BlackMagic
counterpart can be defined as:
type BadMagic<T> = Normalize<{
[K in I<keyof T>]: K extends keyof T ? BadMagic<T[K]> : never
}>
Besides using Normalize
and I
, there's also an extra conditional K extends keyof T
as TypeScript can't infer that the result of applying I
can still index T
. This does not affect the behavior though.
Applying BadMagic
to the sample types yields
type TestObject = BadMagic<{q: string}> // {q: {[x: number]: {[x: number]: BadMagic<string>,...}, toString: {}, charAt: {}, ...}}
type TestString = BadMagic<string> // {[x: number]: {[x: number]: {[x: number]: BadMagic<string>, ...}, toString: {}, charAt: {}, ...}, toString: {}, charAt: {}, ...}
type TestNum = BadMagic<12> // {toString: {}, toFixed: {}, toExponential: {}, toPrecision: {}, valueOf: {}, toLocaleString: {}}
type TestFun = BadMagic<() => any> // {}
Most of the recursion ends at methods that turn into {}
, but if you look at BadMagic<string>
you can see there is in fact some "infinite" recursion, as the [x: number]
property on strings is itself a string. Basically, you have
BadMagic<string> = {
[x: number]: {
[x: number]: {
[x: number]: BadMagic<string>, // "infinite"
...
},
...
},
toString: {},
charAt: {},
...
}
TypeScript playground
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With