2

I am doing typechange recently and I am confused when I come across code like this :

type DeepReadonly<T> = {
  readonly [k in keyof T]: T[k] extends Function?T[k]:DeepReadonly<T[k]>
}

// if we define X1 to test

type X1 = {a: string};
type A = DeepReadonly<X1>

// unbelievable!it works? Shouldn't it be a loop?

// i mean when you give the DeepReadonly the string type, it would loop is not it?

Can someone explain what the typescript does to make this code work...

I really really need your help!!

coatP
  • 23
  • 6
  • 2
    Homomorphic mapped types on primitives just return the primitive unchanged, regardless of the body of the mapped type. So there’s no infinite regress (I assume you were expecting the recursion to go down into all the apparent methods and properties of string?) – jcalz Jan 25 '23 at 02:40
  • Does that address your question fully? If so I could write up an answer explaining; if not, what am I missing? – jcalz Jan 25 '23 at 02:41
  • Also, please review the guidelines for [ask]; the title isn't really appropriate. Could you [edit] the question to conform more to the guidelines? – jcalz Jan 25 '23 at 04:53
  • I think I have understood the explanation you gave. It is best to write an answer if it is convenient for you. – coatP Jan 25 '23 at 09:44

1 Answers1

2

The type

type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends Function ? T[K] : DeepReadonly<T[K]>
}

is considered a homomorphic or structure-preserving mapped type, where you are explicitly mapping over the keys of another type (e.g., in keyof). See What does "homomorphic mapped type" mean? for more details.

According to microsoft/TypeScript#12447 (where these are called "isomorphic" instead of "homomorphic"), emphasis mine:

A mapped type of the form { [P in keyof T]: X }, where T is some type parameter, is said to be an isomorphic mapped type because it produces a type with the same shape as T. ... [W]hen a primitive type is substituted for T in an isomorphic mapped type, we simply produce that primitive type. For example, when { [P in keyof T]: X } is instantiated with A | undefined for T, we produce { [P in keyof A]: X } | undefined.


So, because string is a primitive type, DeepReadonly<string> evaluates immediately to string without even consulting the body, and therefore without evaluating string[K] extends Function ? string[K] : DeepReadonly<string[K]>.

And thus your type A only recurses down one level before terminating:

type X1 = { a: string };
type A = DeepReadonly<X1>;
/* type A = {
    readonly a: string;
} */

That answers the question as asked. Still, it's worth noting that TypeScript can represent recursive data structures without hitting a "loop" in the type instantiation:

interface Tree {
  value: string;
  children: Tree[]
}

type B = DeepReadonly<Tree>
/* type B = {
    readonly value: string;
    readonly children: readonly DeepReadonly<Tree>[];
} */

The Tree type and therefore the B type are defined recursively, but it doesn't break anything. There is a loop conceptually but the compiler doesn't hit a loop.

So even if DeepReadonly<string> were not a homomorphic mapped type, nothing catastrophic would have happened; instead you'd get a fairly ugly recursive type where all of string's apparent members would be enumerated and modified:

type NonHomomorphicDeepReadonly<T> = keyof T extends infer KK extends keyof T ? {
  readonly [K in KK]: NonHomomorphicDeepReadonly<T[K]>
} : never;

type C = NonHomomorphicDeepReadonly<string>;
/* type C = {
    readonly [x: number]: ...;
    readonly [Symbol.iterator]: {};
    readonly toString: {};
    readonly charAt: {};
    readonly charCodeAt: {};
    readonly concat: {};
    readonly indexOf: {};
    readonly lastIndexOf: {};
    readonly localeCompare: {};
    ... 34 more ...;
    readonly padEnd: {};
} */
type D = C[0][0][0][0];
/* type D = {
    readonly [x: number]: ...;
    readonly [Symbol.iterator]: {};
    readonly toString: {};
    readonly charAt: {};
    readonly charCodeAt: {};
    readonly concat: {};
    readonly indexOf: {};
    readonly lastIndexOf: {};
    readonly localeCompare: {};
    ... 34 more ...;
    readonly padEnd: {};
} */

which probably isn't what anyone would want (which is why homomorphic mapped types on primitives behave the way they do), but it's a perfectly acceptable type.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360