Spread operator in typescript doesn't guarantee correct type Spread operator in typescript doesn't guarantee correct type typescript typescript

Spread operator in typescript doesn't guarantee correct type


This is an interaction between the following type system facts:

  1. this is really of the polymorphic this type. Polymorphic this is an implicit generic type parameter of the class that extends the current class. (reference see last paragraph)
  2. 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
  3. An intersection is assignable to any of its constituents. So for example the type A & B is assignable to both A and B regardless of any incompatibilities between any of the properties of A and B (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 parameter T, the type { ...T, ...T } wouldn't actually be assignable to T, 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        }    }}

Playgournd link


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.