Recently I wrote a type that replaces all booleans and numbers in an object with strings.
type AllStrings<T> = {
[Key in keyof T]:
T[Key] extends number
? string
: T[Key] extends boolean
? string
: AllStrings<T[Key]>;
}
What I do not understand, why is it working correctly without an edge case like T[Key] extends string ? string : AllStrings<T[Key]>
.
If I use this type like so:
interface Person { name: string }
type NewPerson = AllStrings<Person>
Why does not it go into an infinite loop? Why TS understands that name
in NewPerson
is a string
? Is this behavior documented somewhere? Is it intended or will be possible that it breaks with another version?
CodePudding user response:
Why does it work without an edge case (filter) ?
When mapping over Person
, the AllStrings
type will also go over name: string
and determine that it is neither a number
nor a boolean
. It will then call itself with AllStrings<string>
. You might assume that execution will continue here since the string
type also has lots of properties to map over. But in fact, mapping over primitive types will just evaluate to the primitve itself. You can read about that in the (lesser known) TypeScript-FAQ.
From the FAQ:
Mapped types declared as { [ K in keyof T ]: U } where T is a type parameter are known as homomorphic mapped types, which means that the mapped type is a structure preserving function of T. When type parameter T is instantiated with a primitive type the mapped type evaluates to the same primitive.
So AllStrings<string>
just evaluates to string
which ends the recursion.
Will be possible that it breaks with another version?
This will likely not break in another version. But be aware that your type breaks in other scenarios. Imagine you are passing a Person
with a Date
property.
interface Person { name: Date }
type NewPerson = AllStrings<Person>
Date
is not a primitve so the mapped type will map over the Date
and will replace all the number
and boolean
properties. TypeScript does not do us a favor in properly displaying the type here. But you can check what happens when you pass a Date
to AllStrings
.
type NewPerson2 = AllStrings<Date>
// type NewPerson2 = {
// toString: AllStrings<() => string>;
// toDateString: AllStrings<() => string>;
// toTimeString: AllStrings<() => string>;
// toLocaleString: AllStrings<{
// (): string;
// (locales?: string | ... 1 more ... | undefined, options?: Intl.DateTimeFormatOptions | undefined): string;
// }>;
// ... 40 more ...;
// [Symbol.toPrimitive]: AllStrings<...>;
// }
Mapping recursively through a Date
and changing its types is probably not what your type was intended to do.