Home > Mobile >  How is TS able to resolve infinitely recursive type correctly?
How is TS able to resolve infinitely recursive type correctly?

Time:10-02

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.


Playground

  • Related