Subscribe on changes!

this.data not properly retaining typescript types when used in methods

avatar
May 1st 2022

Version

3.2.33

Reproduction link

www.typescriptlang.org/play

Steps to reproduce

Using Vue 3 and typescript, define a component:

export default defineComponent({
    name: 'Test',

    data() {
        return {
            test: new Child(),
        };
    },
    
    methods: {
        testMethod: function () {
            this.test = new Child(); 
            let test2 = new Child();
        }
    },
});


class Child {
    b = 0;
}

In the method, the parser thinks that test2 is of type Child. But it thinks that this.test is of type {b: number} (i.e. it has lost the connection to Child).

In this example, it's not a huge deal, because it could easily be recast. But if Child is instead defined as:

class Parent { private a = 1; } class Child extends Parent { b = 0; }

then this.test still has the same type {b: number} (it loses the private member) and can no longer be cast to Parent. This causes endless typecasting to any to work around.

Another symptom it isn't picking up the right type is that something like this.test.a = 3 generates property does not exist (ts2339) while test2.a = 3; generates property is private and only accessible with parent (ts2341) (which is the correct error).

What is expected?

this.test should have type Child

What is actually happening?

this.test has type {b: number}

avatar
May 2nd 2022

this is caused by Ref-Unwrapping happening in reactive objects. As a side effect, classes are essentially reduced to their interface.

As you said, when using simple classes, it's not a big deal in most situations. when using more complex ones with private fields, internal state, side effects and so forth, I would argue that it's better to keep the instance raw anyway, and (at least in composition API, which has better Typescript inference), this would then work as you expect:

Playground

import { defineComponent, ref, shallowRef } from 'vue'


class Parent {
private a = 1
}
class Child extends Parent {
    b = 0;
};

export default defineComponent({
    name: 'Test',

    setup() {
        const child = shallowRef(new Child())

        const functionInSetup = () => {
            const _child = child.value
            _child.a = 0

        }
        return {
            child
        }
    },    
    methods: {
        testMethod: function () {
            // interestingly enough, also works in Options API methods when
            // defined in setup with shallowRef
            let child = this.child
            child.a = 0
        }
    },
});

For Options API, for now, you would have to cast the value, unfortunately. Because even when doing the same thing in data that works in setup, it doesn't infer correctly:

data () {
  return {
     // still won't be recognized as instance of `Child` in options API methods. 
     child: shallowRef(new Child())
  }
}

MAybe some TS guru wants to take a stab at this, but I'm not too optimistic.

avatar
May 2nd 2022

This is super helpful, @LinusBorg. Thank you. I've had an outstanding question on stack overflow for a couple of months, if you want to post the same answer there for some credit. :)