Spread operator in typescript doesn't guarantee correct type
This is an interaction between the following type system facts:
this
is really of the polymorphicthis
type. Polymorphicthis
is an implicit generic type parameter of the class that extends the current class. (reference see last paragraph)- If the spread operation involves operands of a generic type, the result is an intersection of the types of the items that were spread. reference
- An intersection is assignable to any of its constituents. So for example the type
A & B
is assignable to bothA
andB
regardless of any incompatibilities between any of the properties ofA
andB
(example) (reference see Assignment compatibility)
Applying these 3 rules here we get the type of { ...this, b: 99 }
as this & { b: number }
(proof). Which the is assignable to A
since this extends A
.
If you were for example to type assert this
to A
, you would actually get an error, since spread operations that do not involve generic operands are typed correctly (ex)
As to if this is a bug, I would call it a design limitation. For a long time, using spread with generically typed operands was not possible. This feature was added in 3.2 (PR) and reading the notes in the PR we can clearly see that the team is aware of some of the holes here:
An alternative to using intersections would be to introduce a higher-order type operator
{ ...T, ...U }
along the lines of what #10727 explores. While this appears attractive at first, it would take a substantial amount of work to implement this new type constructor and endow it with all the capabilities we already implement for intersection types, and it would produce little or no gain in precision for most scenarios. In particular, the differences really only matter when spread expressions involve objects with overlapping property names that have different types. Furthermore, for an unconstrained type parameterT
, the type{ ...T, ...T }
wouldn't actually be assignable toT
, which, while technically correct, would be pedantically annoying.
The emphasis above was added, your use case falls into this exact problem.
I do not know the exact explanation, but the reason that this is happening is the type of this
.
class A { constructor(readonly a: number, readonly b: string[]){} copy(): A { // must return an object of type A const obj = { ...this, b: 99 // should be of type string[]! } return { ...this, b: 99 // should be of type string[]! } }}
In the above code, the type of this
is this
instead of A
. So the type of object obj
is -
this & { b: number;}
which is also the type of returned object and is somehow assignable to A
(I don't know the reason). Playground
The solution is - add the type of this in the function signature
class A { constructor(readonly a: number, readonly b: string[]){} copy(this: A): A { // must return an object of type A// ^^^^^^^^^ const obj: A = { ...this, b: 99 // should be of type string[]! // error } return { ...this, b: 99 // error } }}
While adding this
typed with the class let the compiler check the type correctly (like suggested in Shivam Singla's answer), it doesn't solve all issues, because of this
.
So I ended up with explicit new
calls in all methods.
class A{ constructor(readonly a: number, readonly b: string[]){} copy(): A { return new A(this.a, 99) }}
The approach can be refined with a parameter object, what is especially useful if the constructor has many parameters but only one or a few should be changed. The following example shows this approach:
interface PointParams { readonly x?: number readonly y?: number}class Point{ constructor(readonly x: number, readonly y: number) {} copy(params: PointParams): Point { return new Point(params.x ?? this.x, params.y ?? thhis.y) }}
I also tried Object.assign
but the spread expression is only syntactic sugar for it, so nothing changed.