Buntel
Buntel

Reputation: 622

Why am I getting Error: TS2322 even though field value is of corresponding type?

I constructed some complex types and now I'm getting error: TS2322. I guess best is to show you the failing code:

enter image description here

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: enter image description here

and a link to typescript playground

Further Explanation

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:

I can define each CallType like this:

type ApiRequestTypeMap = {
  [ApiCallType.SET_USERNAME]: { username: string }
  [ApiCallType.GET_USER]: {}
}

Summary

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

Answers (1)

jcalz
jcalz

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.

Playground link to code

Upvotes: 3

Related Questions