typescriptgenerics

Typescript can't deduce generic return type


I just came across this issue and I find it puzzling. I'm not a typescript expert, so I might be doing some wrong logic leap somewhere, please let me know if I'm wrong.

Am I doing anything wrong? Is any of my assumptions wrong? Or is it a limitation of typescript?

export enum MyEnum {
  A = "A",
  B = "B",
}

export type MyTypeA = {
  type: MyEnum.A;
  data: {
    a: string;
  };
};

export type MyTypeB = {
  type: MyEnum.B;
  data: {
    b: string;
  };
};

export type MyType = MyTypeA | MyTypeB;

export type MyTypeConfig<T extends MyType> = {
  getManipulatedData: (mt: T) => string;
};

export const getMyTypeConfig = <T extends MyType>(myTypeObject: T): MyTypeConfig<T> => {
  switch (myTypeObject.type) {
    case MyEnum.A:
      // this is cool, it knows myTypeObject is of type MyTypeA because of the case match
      console.log(myTypeObject.data.a)
      return {
        // this is NOT cool, it can't deduce that it should return MyTypeConfig<MyTypeA>
        getManipulatedData: (mt: MyTypeA) => mt.data.a + "A",
      };
    case MyEnum.B:
      return {
        // same here, this is not cool
        getManipulatedData: (mt: MyTypeB) => mt.data.b + "B",
      };
  }
};

You can check the TS playground here


Solution

  • The main problem here is you're trying to use generics and control-flow based narrowing at the same time, and those features don't work very well together. You're using control flow (switch/case) to check myTypeObject.type, but that only serves to narrow myTypeObject from the generic type T (which is constrained to the MyType union) to something equivalent to T & MyTypeA or T & MyTypeB. It does not narrow or re-constrain T itself. So even if myTypeObject is of type MyTypeA, the type T stays stubbornly constrained to the full MyType union, and TypeScript cannot verify that the values you're returning are of type MyTypeConfig<T>.

    There is a longstanding open feature request at microsoft/TypeScript#33014 to allow control flow checks to affect type parameters like T, but so far it's not part of the language.


    Therefore you'll either need to refactor to avoid generics, or to avoid using control flow narrowing. Since you apparently need generics (since the return type of getMyTypeConfig() depends on its input type) then we have to get rid of control flow narrowing.

    There are few operations on generics that TypeScript "understands", but the one that comes closest to representing control flow analysis is when you index into an object type with a generic index. If you have an object of type T and index into it with a key of generic type K, then TypeScript will see that as producing a value of the indexed access type T[K].

    So if we can rewrite your MyConfig<T> type as something that indexes into a mapping object type, and reimplement getMyTypeConfig() so that it performs such an indexing, then it should type check. First the mapping object type:

    export type MyTypeConfigMap = {
      [T in MyType as T["type"]]: {
        getManipulatedData: (mt: T) => string
      }
    }
    /* type MyTypeConfigMap = {
        A: {
            getManipulatedData: (mt: MyTypeA) => string;
        };
        B: {
            getManipulatedData: (mt: MyTypeB) => string;
        };
    } */
    

    That uses key remapping in mapped types to turn your MyType union into an object type whose keys are the members of MyEnum. Then MyTypeConfig can be written as

    export type MyTypeConfig<T extends MyType> =
      MyTypeConfigMap[T["type"]]
    

    where you just index into MyTypeConfigMap with a key of type T["type"]. And now the implementation of getMyTypeConfig() looks like:

    const getMyTypeConfig = <T extends MyType>(myTypeObject: T): MyTypeConfig<T> => {
      const m: MyTypeConfigMap = {
        [MyEnum.A]: { getManipulatedData: mt => mt.data.a + "A" },
        [MyEnum.B]: { getManipulatedData: mt => mt.data.b + "B" }
      };
      const t: T["type"] = myTypeObject.type;
      return m[t]
    };
    

    Here m is an object of type MyTypeConfigMap, and t is a key of type T["type"], and so m[t] is a value of type MyTypeConfig<T>. See how indexing into m with t is similar to a switch/case on t, but there are no explicit branches in the code to which control flow analysis would apply.


    That's the answer to the question as asked, but in cases like this where you're hoping TypeScript will see arbitrary higher-order relationships in values of discriminated union types, there is a recommended refactoring at microsoft/TypeScript#47109 to turn everything into base object types, mapped types over those object types, and generic indexes into them. Without explaining the exact reasons why (interested readers could look at ms/TS#47109), the refactoring for your example code looks like

    interface MyTypeData {
      [MyEnum.A]: { a: string },
      [MyEnum.B]: { b: string }
    }
    
    type MyType<K extends MyEnum = MyEnum> =
      { [P in K]: { type: P, data: MyTypeData[P] } }[K]
    
    type MyTypeConfigMap = { [P in MyEnum]: {
      getManipulatedData: (mt: MyType<P>) => string;
    } }
    
    type MyTypeConfig<K extends MyEnum> = MyTypeConfigMap[K];
    
    const getMyTypeConfig = <K extends MyEnum>(myTypeObject: MyType<K>): MyTypeConfig<K> => {
      const m: MyTypeConfigMap = {
        [MyEnum.A]: { getManipulatedData: mt => mt.data.a + "A" },
        [MyEnum.B]: { getManipulatedData: mt => mt.data.b + "B" }
      };
      return m[myTypeObject.type]
    };
    

    Now your MyType and MyTypeConfig types are all written in terms of the members of MyEnum directly, and fundamentally are operations over the MyTypeData base object type. This is the most "natural" representation for TypeScript.

    Playground link to code