admin管理员组

文章数量:1391977

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:

  • Explicit casting
this.interfaceType = testObject as TypeMap[Types.Two]

But the error persists.

  • Ensuring TypeMap is a union (|) and not an intersection (&) The TypeMap definition appears correct, yet TypeScript still infers an intersection.

  • Checking whether TypeScript is incorrectly treating TypeMap[T] as an intersection of all possible values It seems that TypeScript is not properly distributing TypeMap[T] based on T.

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

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:

  • Explicit casting
this.interfaceType = testObject as TypeMap[Types.Two]

But the error persists.

  • Ensuring TypeMap is a union (|) and not an intersection (&) The TypeMap definition appears correct, yet TypeScript still infers an intersection.

  • Checking whether TypeScript is incorrectly treating TypeMap[T] as an intersection of all possible values It seems that TypeScript is not properly distributing TypeMap[T] based on T.

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

Share Improve this question edited Mar 12 at 12:45 d1r0l asked Mar 11 at 19:20 d1r0ld1r0l 234 bronze badges 4
  • 1 I don't know anything about mongoose and presumably this issue is ms/TS#30581 like in this question. If you don't get enough engagement here, consider editing your example to be pure TS without relying on mongoose. – jcalz Commented Mar 11 at 19:26
  • Thank you for this advice. I've updated question to be pure TS related. – d1r0l Commented Mar 12 at 8:02
  • So this does look like ms/TS#30581; generics and discriminated unions don't directly play nicely together. You can refactor to be more generic as mentioned in ms/TS#47109 and as shown [in this playground link](thttps://tsplay.dev/WkJK1m). Does that fully address the question? If so I'll write an answer or find a duplicate; if not, what am I missing? – jcalz Commented Mar 12 at 12:11
  • Yes, you are right, but even with insights from ms/TS#47109 I struggled to come up with a simple solution on my own. The playground example does address the question, though I find the syntax used in it a bit complex and hard to understand. – d1r0l Commented Mar 12 at 12:43
Add a comment  | 

1 Answer 1

Reset to default 2

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 // 

本文标签: