Search code examples
typescriptmapped-types

What is the diference between "{[K in keyof T]: T[K] }" and just "T"


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"

Solution

  • 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:

    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.

    Playground link to code