Reputation: 622
I constructed some complex types and now I'm getting error: TS2322. I guess best is to show you the failing code:
ApiCall.type
and ApiResponse.type
has the same type: ApiCallType
ApiCallType
is enum holding the possible types:
enum ApiCallType {
SET_USERNAME = 'set-username',
GET_USER = 'get-user'
}
Why is this code failing, even though ApiCall.type
and ApiResponse.type
are of the same type and ApiCallType.SET_USERNAME
+ ApiCallType.GET_USER
(all possible options) both work?
Here is picture of the complete error:
and a link to typescript playground
I'm developing a websocket API. Both sites server and client should be able to use the types. I'm not so familiar with typescript. This is my approach to describe the data I wanna send. If a websocket message arrives, I consider its data to be an ApiCall
which is ApiRequest
or ApiResponse
.
I can distinguish them by a success or error field, which indicates that it has to be an ApiResponse
.
Both ApiRequest
and ApiResponse
have the following fields:
ApiCallType
I can define each CallType like this:
type ApiRequestTypeMap = {
[ApiCallType.SET_USERNAME]: { username: string }
[ApiCallType.GET_USER]: {}
}
Important Types | description |
---|---|
ApiCallType | enum describing possible Response/Request types |
ApiCall | ApiResponse or ApiRquest |
ApiRequest | participant who initiates an communication is sending an request |
ApiResponse | reaction to request |
ApiResponseTypeMap | holds definitions for each ResponseType |
ApiRquestTypeMap | holds definitions for each RequestType |
ApiResponseSuccess | success: true |
ApiResponseError | error: string |
Upvotes: 1
Views: 589
Reputation: 329658
It really helps to provide a minimal reproducible example that demonstrates the issue with as little code as possible. In your case, the problem is this:
type Union = { k: 1, v: string } | { k: 2, v: string } | { k: 1, z: number };
const u: Union = { k: Math.random() < 0.5 ? 1 : 2, v: "" }; // error!
// ~
// Type '{ k: 1 | 2; v: string; }' is not assignable to type 'Union'.
Here we have a type named Union
and a value named u
whose type is something like {k: 1 | 2, v: string}
. It seems obvious to a human being that u
must either be a value of type {k: 1, v: string}
or {k: 2, v: string}
and thus assignable to Union
. That is, as human beings, we naturally imagine propagating the union from the property up to the object itself. But the compiler does not do this (see microsoft/TypeScript#8289). The type {k: 1|2, v: string}
is not assignable to any single constituent of the Union
union, and so the assignment fails. That's the full explanation...
...or, rather, that was the full explanation prior to TypeScript 3.5's introduction of smarter union type checking, as implemented in microsoft/TypeScript#30779. Now, there are times where unions are propagated up from properties. Here's a minor modification which makes things work in TypeScript 3.5+:
type Vnion = { k: 1, v: string } | { k: 2, v: string }; // removed { k: 1, z: number }
const v: Vnion = { k: Math.random() < 0.5 ? 1 : 2, v: "" }; // okay
It might be surprising that merely by removing {k: 1, z: number}
from the union type would make the assignment to Vnion
work, especially because {k: 1, z: number}
did not enter into our mental model of why the assignment to Union
failed.
The answer for why this matters is a bit involved, but can be found in the notes for microsoft/TypeScript#30779:
For each matching constituent of 'target', we relate the remaining non-discriminant properties of 'source' to 'target'. If any remaining properties are not related, we determine 'source' is not related to 'target'.
In the case here, 'source' has been expanded from {k: 1|2, v: string}
to {k: 1, v: string} | {k: 2, v: string}
and 'target' is Union
, which is { k: 1, v: string } | { k: 2, v: string } | { k: 1, z: number }
. Since k
, the discriminant property, is present in {k:1, z:number}
, the compiler checks the "non-discriminant" property of source
, v
, against {k:1, z:number}
. It isn't related, and so the whole assignment fails.
I don't fully understand why this check happens; presumably leaving it out would allow some spurious or unexpected assignments to take place. But it has the effect of blocking the sort of assignment you're doing, unfortunately.
And that is (closer to) the full explanation.
So what can you do about it? Well, as in all cases where you know more about the type of some value than the compiler does, you can use a type assertion to just tell the compiler not to worry so much and that you have taken care of verifying type safety here:
const w = { k: Math.random() < 0.5 ? 1 : 2, v: "" } as Union; // okay
That's probably what I'd recommend, since it changes your code the least.
Another workaround is to refactor the value you're assigning so that it starts off life as a union instead of an object whose property is a union:
const x: Union = { ...Math.random() < 0.5 ? { k: 1 } : { k: 2 }, v: "" }; // okay
That works because you've got a value of type {k: 1} | {k: 2}
instead of {k: 1 | 2}
.
Finally, you could make a function that does nothing at runtime, but gives the compiler explicit instructions to propagate a union from a single property up to the top:
const y: Union = expandProperty({ k: Math.random() < 0.5 ? 1 : 2, v: "" }, "k")
This is possible but then you need to maintain and explain the expandProperty()
function, which might look like this:
type ExpandProperty<T, K extends keyof T, V extends T[K] = T[K]> =
V extends any ? { [P in keyof T]: P extends K ? V : T[P] } : never;
const expandProperty = <T, K extends keyof T>(obj: T, k: K) =>
obj as ExpandProperty<T, K>;
So I'd only recommend this in case you find yourself running into this union-propagation issue often and don't want to have to either assert your way out each time or refactor your code.
Upvotes: 3