I tried to implement the following pattern
type ClassProps = {
a: number,
b: string,
}
class Example implements ClassProps {
public a: number
public b: string
constructor(props: ClassProps) {
let key: keyof ClassProps
for(key in props) {
this[key] = props[key]
}
}
}
It compiles and runs just fine as far as I can tell.
But VSCode (Code - OSS 1.73.0
) raises an error at the assignment because it infers the type of this[key]
to be never
.
Type 'string | number' is not assignable to type 'never'.
I would like to understand why this happens and how to fix it.
The linter is also not clever enough to figure out that a and b here are in fact definitely assigned in the constructor and complains about it - but that is easily remedied with the !
operator.
But for the other error, the only way I found so far to fix it was implicitly or explicitly typing the class properties to any - losing all typing support.
CodePudding user response:
This is a limitation or missing feature of TypeScript. The specific problem with copying a property between two objects of the same type is the subject of microsoft/TypeScript#32693, but the general problem is the lack of support for "correlated unions" as described in microsoft/TypeScript#30581. If you look at the assignment
this[key] = props[key]; // error!
The compiler essentially analyzes only the types of these values, and not their identities. On the right hand side, it sees you indexing into a ClassProps
with a union-typed key of type "a" | "b"
for reading (also known as the "source"), and thus you have a value of type string | number
. On the left hand side, it sees you indexing into an Example
with a union-typed key of type "a" | "b"
for writing (also known as the "target"). For that to be considered safe, according to microsoft/TypeScript#30769, it requires the target to be the intersection of the relevant property types: so it must be of type string & number
(which is immediately reduced to the impossible never
type, since there are no values which are both string
and number
at the same time). And since string | number
is not assginable to string & number
, the compiler issues an error.
That error would be completely reasonable if you were faced with the following assignment instead:
declare const key1: keyof ClassProps;
declare const key2: keyof ClassProps;
this[key1] = props[key2]; // error!
After all, you can't write a random property from props
to a random property in this
. What if you are reading a string
and writing to a number
?
And, sadly, the compiler does not distinguish between that case and this[key] = props[key]
. It doesn't realize that the types on either side of the assignment are correlated and cannot possibly be mismatched.
Until and unless anything changes here, we need to use workarounds, or to refactor. The recommended refactorings usually involve generics. One exception to the union-source-to-intersection-target rule is if the source and intersection are both identical generic types. So instead of key
being of type keyof ClassProps
, you can make it of type K extends keyof ClassProps
.
For example, if you refactor to a generic function like:
function copyProp<T, K extends keyof T>(target: T, source: T, key: K) {
target[key] = source[key]; // okay
}
That compiles because both sides of the equation are type T[K]
. And then you can call this function:
constructor(props: ClassProps) {
let key: keyof ClassProps
for (key in props) {
copyProp(this, props, key); // okay
}
}
If you don't want to spend time refactoring, you could just use a type assertion and move on with your life:
this[key] = props[key] as (string & number); // okay
It's up to you and what your use case is.