When looking at the DeepReadonly<T>
type:
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends Function ? T[K] : DeepReadonly<T[K]>
}
it falls under the category of being a homomorphic or structure-preserving mapped type. This means that it explicitly maps over the keys of another type using in keyof
. For more information, you can refer to this resource on "homomorphic mapped types".
In the context of Microsoft's TypeScript documentation (microsoft/TypeScript#12447), they are referred to as "isomorphic" mapped types. Here is an excerpt for clarity:
A mapped type written as { [P in keyof T]: X }
, where T
is a type parameter, is known as an isomorphic mapped type because it produces a type with the same shape as T
. It states that when a primitive type replaces T
in an isomorphic mapped type, the result will be that primitive type.
Hence, since string
is a primitive type, DeepReadonly<string>
directly evaluates to string
without going into the details of evaluating
string[K] extends Function ? string[K] : DeepReadonly<string[K]>
.
This explains why your type A
only delves one level deep before coming to an end:
type X1 = { a: string };
type A = DeepReadonly<X1>;
/* type A = {
readonly a: string;
} */
The above explanation adequately answers the initial query. However, it is noteworthy that TypeScript has the capability to represent recursive data structures without causing any issues during type instantiation:
interface Tree {
value: string;
children: Tree[]
}
type B = DeepReadonly<Tree>
/* type B = {
readonly value: string;
readonly children: readonly DeepReadonly<Tree>[];
} */
Even though the Tree
type and resultant B
type are defined recursively, there are no pitfalls encountered. Although conceptually there may be a loop, the compiler does not encounter one.
Hence, even if DeepReadonly<string>
wasn't a homomorphic mapped type, it would still lead to a valid but complex recursive type where all apparent members of string
would be enumerated and altered:
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: {};
} */
While this might not be ideal, it illustrates the rationale behind why homomorphic mapped types behave as they do with primitive types. Ultimately, it results in an acceptable type structure.
Playground link to code