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`
}
}
}
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!
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.