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
1 Answer
Reset to default 2The 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 //
本文标签:
版权声明:本文标题:Why does TypeScript infer an intersection type (&) instead of a union (|) when assigning a generic mapped type? - Stack 内容由网友自发贡献,该文观点仅代表作者本人,
转载请联系作者并注明出处:http://www.betaflare.com/web/1744774973a2624568.html,
本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容,一经查实,本站将立刻删除。
发表评论