Search code examples
typescripttypescript-generics

Typescript generic type param not working as expected when used as Index in mapped Types


I am unable to understand typescript behaviour when accessing mapped types using a generic param, here the simplest code that triggers an error:

export type mockInstances = {
  app: {
    id: number;
    code: string;
  };
  setup: {
    id: number;
    code: string;
  };
};

type MockEntityName = keyof mockInstances;

type MockInstance<EntityName extends MockEntityName> =
  mockInstances[EntityName];

const testWrapper = <EntityName extends MockEntityName>() => {
  //Errors
  const a: Partial<MockInstance<EntityName>> = {
    id: 1,
  };
  const b: Partial<MockInstances[EntityName]> = {
    id: 2,
  };
  console.log('ab', a, b);

  //Error: Type '{ id: 1; }' is not assignable to type 'Partial<MockInstance<EntityName>>'.ts(2322)

  //Works
  const c: Partial<MockInstance<'app'>> = {
    id: 1,
  };
  const d: Partial<MockInstances['app']> = {
    id: 2,
  };

  const e: Partial<MockInstances[mockEntityName]> = {
    id: 2,
  };

  console.log('cde', c, d, e);
};

I was expecting the typescript compiler to be able to have a and b "assignable" to the indexed type using the EntityName param (as index or type param).

Here an even simplified version of the the weird typescript behaviour:

export type mockInstances = {
  setup: {
    id: number;
    code: string;
    entityName: string;
    desc: string;
  };
};

type MockInstance<EntityName extends 'setup'> = mockInstances[EntityName];

const testWrapper = <EntityName extends 'setup'>(entityName: EntityName) => {
  //Error: Type '{ id: 1; code: "test"; entityName: EntityName; }'
  // is not assignable to type 'Partial<MockInstance<EntityName>>'.ts(2322)
  const a: Partial<MockInstance<EntityName>> = {
    id: 1,
    code: 'test',
    entityName,
  };

  const b: Partial<MockInstance<'setup'>> = {
    id: 1,
    code: 'test',
    entityName,
  };

  console.log(a, b);
};

So I have narrowed down to the fact that Partial does not behave like I would expect when dealing with generics, I am fearing this could be considered as a bug...

Thank you for any light regarding this issue.


Solution

  • The problem is that TypeScript is rarely able to make higher-order conclusions about generic types. There are only specific approaches that work, and these are largely described in microsoft/TypeScript#47109.


    Your MockInstances type is equivalent to Record<MockEntityName, { id: number, code: string }>, but they are not represented the same way.

    Your version is just a manually-written-out object type, where Record is a mapped type where it's known that the property value type is independent of the key. The compiler would need to analyze your version and somehow recognize its equivalence to the mapped type. But it doesn't do this. Such conclusions require either human reasoning (which the compiler has yet to be able to perform) or someone programming the compiler to check for such things... meaning the compiler would spend a lot of time checking for this sort of thing in lots of places where it's useless (e.g., if you, say, modified one of the properties but not the other). Even if it were possible to do, and a common need, it might not be worth the performance hit.

    If you want the compiler to know that MockInstances's properties are always {id: number, code: string}, then you should represent it explicitly that way:

    type MockEntityName = "app" | "setup";
    type MockInstances = Record<MockEntityName, { id: number, code: string }>;
    

    Next, you'd hope that the compiler would understand that, independently of K, that Partial<MockInstances[K]>> will be {id?: number, code?: string}. But again, this requires a sort of generic analysis the compiler doesn't do. It would have to abstract over the nested mapped type type PartialMockInstances<K> = Partial<MockInstances[K]> to see that K doesn't matter. And again, it cannot do this. If you want the compiler to see that equivalence, you'll need to write it out as a mapped type over K:

    type PartialMockInstances =
        { [P in MockEntityName]: Partial<MockInstances[P]> };
    

    If you use IntelliSense to inspect PartialMockInstances, you'll see it's equivalent to

    type PartialMockInstances = {
        app: Partial<{
            id: number;
            code: string;
        }>;
        setup: Partial<{
            id: number;
            code: string;
        }>;
    }
    

    So now instead of writing Partial<MockInstances[K]>>, you can write PartialMockInstances[K], which involves indexing into a mapped type, about which (see microsoft/TypeScript#47109) the compiler can reach helpful conclusions:

    const testWrapper = <K extends MockEntityName>() => {
        const a: PartialMockInstances[K] = {
            id: 1,
        }; // okay
    };
    

    Here, PartialMockInstances[K] is a distributive object type (again, see ms/TS#47109), and the compiler is happy about assigning things to those.

    Playground link to code