Spread operator in typescript doesn't guarantee correct type

Viewed 1002

I'm using the spread operator to create a copy with a changed field like in the following example (run). But the compiler doesn't complain if the type of the field is changed when the object literal is created:

class A{
    constructor(readonly a: number, readonly b: string[]){}
    copy(): A { // must return an object of type A
        return {
            ...this,
            b: 99 // should be of type string[]!
        }
    }
}

const a: A = new A(1, ["x", "y"])
const a1 = a.copy()
console.log(JSON.stringify(a1))

Output:

[LOG]: "{"a":1,"b":99}"

Creating an explicit interface also doesn't help:

interface IA {
    readonly a: number
    readonly b: string[]
}

...

copy(): IA { ...

It looks like it had nothing to do with arrays, since the next example compiles as well:

class A{
    constructor(readonly a: number, readonly b: number){}
    copy(): A {
        return {
            ...this,
            b: "my string" // should be of type number!
        }
    }
}

Why is this possible in TypeScript? Or is it a compiler bug? Is there a way to avoid

3 Answers

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.

Related