I found the following generic type in some codebases:
type MyType<T> = {[K in keyof T]: T[K] }
Why is it not the same as type MyType<T> = T
?
I tried to find the difference in the types/values keyof MyType<SomeOtherType>
can have, but failed, are there other implications to the first Type definition?
/* eslint-disable @typescript-eslint/no-unused-vars */
type BaseType = {
a: number
b: string
}
type MyTypeA<T> = {
[K in keyof T]: T[K]
}
type MyTypeB<T> = T
function MyFuncA<T extends BaseType>(a: MyTypeA<T>, key: keyof T) {
const c = a[key]
return c
}
function MyFuncB<T extends BaseType>(a: MyTypeB<T>, key: keyof T) {
const c = a[key]
return c
}
type ExtendedType = BaseType & {
c: boolean // additional property
}
const extendedObject: ExtendedType = {
a: 1,
b: 'test',
c: true,
}
const CA = MyFuncA(extendedObject, 'a') // CA has type "number | sting | boolean"
const CB = MyFuncB(extendedObject, 'a') // CA also has type "number | sting | boolean"
The type
type MyType<T> = { [K in keyof T]: T[K] };
is very similar to just T
. It is a mapped type that maps properties from T
to themselves. And it's a homomorphic mapped type (What does "homomorphic mapped type" mean?) so it:
preserves the readonly
-ness or optionalness of the mapped properties
automatically distributes over union types (so that MyType<A | B>
is equivalent to MyType<A> | <B>
)
automatically preserves primitive types T
instead of iterating over properties (so that MyType<string>
is string
)
automatically preserves the shape of array and tuple types (so that MyType<Array<A>>
is Array<A>
and MyType<[A, B, C]>
is [A, B, C]
).
Thus for a wide range of input types T
, the type MyType<T>
will be structurally equivalent to T
.
But they are not identical.
The biggest difference is that mapped types do not map call signatures or construct signatures. These are completely dropped. The type MyType<T>
is neither callable nor newable, even if T
is:
function func(a: string, b: number) {
return a.length === b
}
func("abc", 123); // okay
type Func = MyType<typeof func>;
// ^? type Func = {}
declare const f: Func;
f("abc", 123); // error!
class Class { a = 1 }
new Class(); // okay
type Ctor = MyType<typeof Class>;
// ^? type Ctor = { prototype: Class }
declare const C: Ctor;
new C(); // error!
The type Func
is just the empty object type {}
, and the type Ctor
is an object type with just a prototype
property (because class constructors have a prototype
property). So you can't call a Func
and you can't new
a Ctor
. If you change your definition of MyType
to type MyType<T> = T
then the above errors go away.
So there is an obvious and meaningful difference between MyType<T>
and T
.
Other differences are more subtle. One is that instead of distributing over intersections, TypeScript will combine the intersected types into a single object type. Thus:
type MyISect = MyType<{ a: string } & { b: number }>;
/* type MyISect = {
a: string;
b: number;
} */
Those types are structurally equivalent, but they are not seen as identical by TypeScript, so in the rare cases where "identical" matters, TypeScript will behave differently. For example, you're allowed to redeclare var
variables, but only with "identical" types:
var i: { a: string } & { b: number };
var i: { b: number } & { a: string }; // okay
var i: { a: string, b: number }; // error!
And sometimes when you intersect two types, the special behavior of homomorphic mapped types breaks down. For example, if you map an array, you get an array:
const arr0 = [1, 2, 3];
type ArrOkay = MyType<typeof arr0>;
// type ArrOkay = number[];
But if you map an intersection of an array with an object, which is a valid type:
const arr1 = Object.assign([1, 2, 3], { a: "abc" });
// const arr: number[] & { a: string; }
You get... something horrendous which is (probably) structurally equivalent to that intersection:
type ArrHorrendous = MyType<typeof arr1>;
/* type ArrHorrendous = {
[x: number]: number;
length: number;
toString: () => string;
toLocaleString: {
(): string;
(locales: string | string[],
options?: Intl.NumberFormatOptions & Intl.DateTimeFormatOptions): string;
};
pop: () => number | undefined;
push: (...items: number[]) => number;
concat: {
(...items: ConcatArray<number>[]): number[];
(...items: (number | ConcatArray<number>)[]): number[];
};
join: (separator?: string) => string;
⋮ ✂ ⋮ ✂ ⋮
map: <U>(callbackfn: (value: number, index: number, array: number ⋯ ✂
⋮ ✂ ⋮ ✂ ⋮
} */
Often types like MyType<T>
can be used to make intersections "prettier" for IntelliSense, but it can backfire dramatically, as you see above.
There are other differences too, but they end up probing the boundaries of various type system behaviors and I don't know how useful they are to list out. For example, object types can be given implicit index signatures, but interface
types cannot, (see microsoft/TypeScript#15300), and since MyType<T>
is not an interface even if T
is, you can see a difference there:
interface Iface { a: string }
const iface: Iface = { a: "abc" };
let x: { [k: string]: string } // index signature
x = iface; // error, Iface not given an implicit index signature
const notIface: MyType<Iface> = iface;
x = notIface; // okay, MyType<Iface> is given an implicit index signature
At this point I'll stop listing things out, and just reiterate that MyType<T>
is similar to T
, but not identical.