Vladislav Khazipov
Vladislav Khazipov

Reputation: 333

How mapped types works with base types?

I can't understand why in the situation below type T0 is a string and not an object with keys "toString" , "slice" "split" ... and why type T1 is an object with the key type string while we know that keyof any is a string | symbol | number?

type MappedType<T> = {
[K in keyof T]: T;
}

type T0 = MappedType<string>;
type T1 = MappedType<any>;

Upvotes: 3

Views: 292

Answers (1)

jcalz
jcalz

Reputation: 327944

An authoritative answer for T0 can be found in the pull request that implemented "homomorphic mapped types", or mapped types of the form {[K in keyof T]: ...} where T is a generic type parameter. (Note that it was originally called "isomorphic" instead of "homomorphic", but it's the same thing.)

The normal use case for such a mapped type is when T is some object type; in this cases, the mapping preserves the key names from T as well as their "read-onliness" and "optionality" modifiers. (It is possible to override these modifiers by explicitly setting them in the mapped type.)

So what happens when T is a primitive type like string? The pull request says "we simply produce that primitive type". This turns out to be very useful, especially in cases where you are using mapped types recursively. A type like

type DeepPartial<T> = { [K in keyof T]?: DeepPartial<T[K]> }

will walk down through a nested object type and make each subproperty optional... and leave primitives alone:

declare const ab: DeepPartial<{ a: { b: string } }>;
const str = ab.a!.b!; // string

This is almost always what people want. If such a type kept on recursing downward into all properties of wrapped primitives like length and toUpperCase, you'd end up with a largely useless type. And then the only way to get the "normal" version would be to use conditional types to terminate the descent instead of mapping primitives:

type DeepPartial2<T> = 
  { [K in keyof T]?: T[K] extends object ? DeepPartial2<T[K]> : T[K] }

which is less convenient (and wasn't possible until TS2.8 introduced conditional types anyway).

In some sense, people usually want to preserve "the same general shape" for things mapped homomorphically, but what "the same general shape" means isn't always obvious. Object types stay object types. Primitives stay primitives. Originally, arrays were treated as regular object types, producing a nearly useless type which transformed array methods into something unrecognizable. This was not pleasant for anyone, which is why TypeScript 3.1 changed it so that mapped array types are still array types, and mapped tuple types are still tuple types.


That leaves us with T1, where a homomorphic mapping over the properties of any yields an indexable type.

The previously linked pull request doesn't go into it, but the implementer explained his reasoning in a comment to a GitHub issue reporting this behavior as a bug:

keyof any is string | number | symbol, so a mapped type applied to any yields the equivalent of { [x: string]: XXX }.

Note that he says "the equivalent of". symbol is automatically removed from mapped types, so {[K in "a" | symbol]: string} yields {a: string}. You are allowed to have both string and number indices separately, so {[K in string | number]: string} is {[k: string]: string; [k: number]: string}. But since numeric keys are considered string-valued in TypeScript and JavaScript, this is the same as {[k: string]: string}. (If you care to read more about what happens to string, number, and symbol keys when mapped over, you can see this documentation.)

Personally, I'm not 100% on board with keyof any being mapped to just a string index instead of both string and number, since {[K in keyof T]: K} would show a difference... but it's a minor point.

The important point is from the rest of the comment:

You could argue that we should just yield any when a homomorphic mapped type is applied to any, but that would be less precise. For example { [K in keyof T]: number } instantiated with T as any really ought to yield { [x: string]: number } and not just any.

So there you go; any is treated like an object type with string-indexable keys so that mapping over it yields something reasonable. And that comment is the closest I've seen to a canonical answer to this question.


Playground link to code

Upvotes: 6

Related Questions