Home > Mobile >  Tricky typing problems with a generic union type doing an intersection type
Tricky typing problems with a generic union type doing an intersection type

Time:12-05

I have stumbled upon a peculiar problem, and I have no idea how to fix this. I have a class with a generic type. This class contains a method with only one union parameters asking for the generic type or an object. Since this class is instantiated multiple times with different types, I wanted to create a generic method that retrieves one of those instances (generically typed), and make a call of the method within.

Here's a code for reproduction:

export interface A {
  a: any;
}
export interface B {
  b: any;
}
export interface MyClasses {
  a: MyClass<A>;
  b: MyClass<B>;
}

export interface C {
  c: any;
}
export declare class MyClass<T = { [prop: string]: any }> {
  myMethod(data: T | C): T;
}

export type MyClassesKeys = keyof MyClasses;
export type MyClassInferGenericType<T> = T extends MyClass<infer G> ? G : T;

export class MyService {
  private myClasses: MyClasses = {
    a: new MyClass<A>(),
    b: new MyClass<B>()
  };

  public getMyClass<T extends MyClassesKeys>(keyName: T): MyClasses[T] {
    return this.myClasses[keyName];
  }

  public callerMethod<T extends MyClassesKeys, U extends MyClassInferGenericType<MyClasses[T]>>(key: T, myData: U) {
    // This part is usually retrieved gener
    const myClassInstance = this.getMyClass(key);

    // My call
    myClassInstance.myMethod(myData);
  }
}

This returns, somehow, a compilation error:

Argument of type 'A | B' is not assignable to parameter of type '(A | C) & (B | C)'.
  Type 'A' is not assignable to type '(A | C) & (B | C)'.
    Type 'A' is not assignable to type 'A & C'.
      Property 'c' is missing in type 'A' but required in type 'C'.

With "myClassInstance" being of type MyClasses[T] and myData taking the generic type associated with MyClasses[T], everything should be correctly inferred.

So, why is typescript trying to do an intersection of my types?

CodePudding user response:

The reason you get an intersection is because the compiler loses track of the correlation between the type of key and the type of myData, and so it only accepts something it knows is safe. If myData were both A and B (like {a: 1, b: 2}), then it would be safe to call myClassInstance.myMethod(myData) no matter which key were passed in.

That's what happens because myClassInstance.myMethod is seen having a union type:

const x = myClassInstance.myMethod;
// const x: ((data: A | C) => A) | ((data: B | C) => B)

And if you call a union of functions, you have to pass in an intersection of its parameters for the compiler to accept it.

Of course, it's impossible for someone to pass in myData and key that are not properly matched. (Actually that's not true, since key could be of a union type. Without ms/TS#27808 it is possible. Let's say that it's possible but unlikely. Or at least that we won't worry about it here.) But the compiler can't see this.

There isn't really support for the compiler to see arbitrarily complex correlations between different types. The basic issue is described in microsoft/TypeScript#30581 which frames it in terms of correlated unions.


Luckily, there is often a way to refactor the types in a way that the compiler is able to follow the logic. This is described in microsoft/TypeScript#47109. The idea is to make a "basic" type that represents your input/output relationship as simply as possible:

interface MyClassGenerics {
    a: A;
    b: B
}

And then we write MyClasses explicitly as a mapped type over that basic type:

type MyClasses = {
    [K in keyof MyClassGenerics]: MyClass<MyClassGenerics[K]>
}

So now, when you write MyClasses[K], the compiler will automatically see its relationship to MyClass<MyClassGenerics[K]>. And you can refer to MyClassGenerics[K] without needing to use MyClassInferGenericType:

public callerMethod<K extends MyClassesKeys>(key: K, myData: MyClassGenerics[K]) {
  const myClassInstance = this.getMyClass(key);

  const x = myClassInstance.myMethod
  // const x: (data: C | MyClassGenerics[K]) => MyClassGenerics[K]

  myClassInstance.myMethod(myData); // okay
}

Now the type of myClassInstance.myMethod is seen to be a single function type whose input and output types depend on the generic type parameter K. It's no longer a union, so it can be called more easily.

Playground link to code

  • Related