typescripttypescript-genericstypescript-typings

Why does TypeScript infer an intersection type (&) instead of a union (|) when assigning a generic mapped type?


I'm working with TypeScript and running into a strange type inference issue when using a generic mapped type (TypeMap).

I have a generic class TestState that maps an enum (Types) to specific interfaces. However, when I try to assign a new object to this.interfaceType, TypeScript infers an intersection type (&) instead of a union (|), which causes a type error.

The issue happens on this line:

this.interfaceType = testObject;

Here's a minimal reproducible example:

interface InterfaceOne {
  fieldOne: string
}

interface InterfaceTwo {
  fieldTwo: string
}

enum Types {
  One = 'one',
  Two = 'two'
}

type TypeMap = {
  [Types.One]: InterfaceOne
  [Types.Two]: InterfaceTwo
}

export default class TestState<T extends Types> {
  private baseType: T
  private interfaceType: TypeMap[T] | null

  constructor(testFieldOne: T, testFieldTwo: TypeMap[T]) {
    this.baseType = testFieldOne
    this.interfaceType = testFieldTwo
  }

  private init(): void {
    if (this.baseType === Types.Two) {
      const testObject: InterfaceTwo = { fieldTwo: 'test' }
      console.log(this.interfaceType)
      this.interfaceType = testObject // ❌ TypeScript infers TypeMap[T] as 'InterfaceOne & InterfaceTwo' instead of `InterfaceOne | InterfaceTwo`
    }
  }
}

What I've Tried:

this.interfaceType = testObject as TypeMap[Types.Two]

But the error persists.

Why is TypeScript inferring an intersection type (&) instead of a union (|) when assigning a generic mapped type? How can I properly assign testObject to this.interfaceType without TypeScript throwing an error?

Any insights or workarounds would be greatly appreciated!

TS Playground Link


Solution

  • The type TestState<T> is not a discriminated union type; indeed, it is not a union type at all. So checking this.baseType === Types.Two cannot narrow the apparent type of this, and therefore this.interfaceType is not known to have type InterfaceTwo. You could decide to try to tell TypeScript that this will be the discriminated union type TestState<Types.One> | TestState<Types.Two> inside the init() method by giving it a this parameter, and that will make init() compile without error:

    private init(this: TestState<Types.One> | TestState<Types.Two>): void {
        if (this.baseType === Types.Two) {
            const testObject: InterfaceTwo = { fieldTwo: 'test' }
            console.log(this.interfaceType)
            this.interfaceType = testObject // okay
        }
    }
    

    but then you can't easily call init() from anywhere unless this has already been narrowed to such a union:

    constructor(testFieldOne: T, testFieldTwo: TypeMap[T]) {
        this.baseType = testFieldOne
        this.interfaceType = testFieldTwo
        this.init(); // error! 
        // 'TestState<T>' is not assignable to 'TestState<Types.One> | TestState<Types.Two>'
    }
    

    So all we've done is move the problem.


    Ultimately you can't really use both control flow analysis and generics together. Well, in TypeScript 5.9, we're likely to see microsoft/TypeScript#61359 enable some amount of generic re-constraining, but it probably won't help with this example.

    So we should give up on either control flow analysis or on generics. Since classes cannot be unions directly, we need generics, so let's try to refactor away from control flow analysis. One way to do this is to replace checks like if (obj.discrim === "x") { } else if (obj.discrim === "y") else with a single generic indexing into a "processing" object like const process = {x: ()=>{}, y: ()=>{}}; process[obj.discrim](). That has a similar effect, but now you can say what's happening with types instead of with following if/else control flow.

    Here's a way to do that:

    private init(): void {
        const process: { [P in Types]?: (t: TestState<P>) => void } = {
            [Types.Two](thiz) {
                const testObject: InterfaceTwo = { fieldTwo: 'test' }
                console.log(thiz.interfaceType)
                thiz.interfaceType = testObject
    
            }
        }
        process[this.baseType]?.(this);
    }
    

    The type of process is a mapped type over the elements P of Types, where each property is a function that operates on the corresponding TestState<P>. So there's a Types.Two method that expects a TestState<Types.Two> as an argument. I've named that parameter thiz since it takes the place of this in your example. Since thiz is known to be a TestState<Types.Two> from the start, then you can do the assignment.

    Then, in order to actually use process, you need to access process[this.baseType] and call it (if it exists, hence the optional chaining (?.)) with this as an argument. So we have process[this.baseType]?.(this), which compiles because process[this.baseType] has the generic type (t: TestState<T>) => void , and this has the type TestState<T>. The fact that TypeScript is able to see process[this.baseType] as the appropriate single function type, and not as a union of two functions (and hence a function that only accepts the intersection of the arguments) is the core reason this works, and you can read more about this at microsoft/TypeScript#47109.

    No, it's not very intuitive to do things this way. But until and unless TypeScript can make control flow analysis and generic work seamlessly together, you need to jump through some hoops to get TypeScript to understand what you're doing.


    Of course, if you don't really care about TypeScript understanding what you're doing and just want it to accept it, you can always just use type assertions (what you called "casting"), like:

    if (this.baseType === Types.Two) {
            const testObject: InterfaceTwo = { fieldTwo: 'test' }
            console.log(this.interfaceType)
            this.interfaceType = testObject as any // 🤷‍♂️
        }
    }
    

    Asserting to the any type is more or less just telling TypeScript that it shouldn't even try to check what you're doing. This is expedient, but obviously less safe than refactoring. It all depends on what your priorities are.

    Playground link to code