mutoe
mutoe

Reputation: 562

How to dynamically declare an instance property of a class from a constructor in Typescript?

I tried to write a simple VUE using typescript, but the first step failed. I found a lot of answers and didn't find a solution that would solve my problem.

I want to dynamically declare some properties of classes, which they get through constructors, but I don't know how to write such declarations.

Environment

typescript 3.4.5

interface IOptions {
  data: () => Record<string, any>
}

class Vue {
  private $options: IOptions = {
    data: () => ({})
  }

  constructor(options: IOptions) {
    this.$options = options
    const proxy = this.initProxy()
    return proxy
  }

  initProxy() {
    const data = this.$options.data ? this.$options.data() : {}

    return new Proxy(this, {
      set(_, key: string, value) {
        data[key] = value
        return true
      },
      get(_, key: string) {
        return data[key]
      }
    })
  }
}

const vm = new Vue({
  data() {
    return {
      a: 1
    }
  }
})

vm.a = 2
// ^ Property 'a' does not exist on type 'Vue'.

console.log(vm.a) // => 2
//             ^ Property 'a' does not exist on type 'Vue'.

This is an online preview of the address https://stackblitz.com/edit/typescript-kh4zmn

Open it and you can see that the console outputs the expected output but the editor gives an typescript error with Property 'a' does not exist on type 'Vue'..

I expect vm to have the correct type so that I can access the properties declared in the constructor without error.

Upvotes: 2

Views: 909

Answers (3)

Titian Cernicova-Dragomir
Titian Cernicova-Dragomir

Reputation: 250246

The first part of the problem is getting the return type of initProxy right. Since you are adding all properties returned by data to the proxy, the return type should contains them. To achieve this we will need a type parameter (T) to the Vue class. This type parameter will capture the actual type of the data return type. With this type parameter in hand we can the let typescript know that initProxy actually returns T & Vue<T>, that is it returns an object that is both T and the original class

interface IOptions<T> {
    data: () => T
}

class Vue<T = {}> {
    private $options: IOptions<T> = {
        data: () => ({})
    } as IOptions<T>

    constructor(options: IOptions<T>) {
        this.$options = options
        const proxy = this.initProxy()
        return proxy
    }
    initProxy(): T & Vue<T> {
        const data = this.$options.data ? this.$options.data() : {}

        return new Proxy(this as unknown as T & Vue<T>, {
            set(_, key: string, value) {
                data[key] = value
                return true
            },
            get(_, key: string) {
                return data[key]
            }
        })
    }
}

const vm = new Vue({
    data() {
        return {
            a: 1
        }
    }
})
vm.initProxy().a // ok now

The second part of the problem is that although typescript will let you return an object from the constructor, this will not in any way change the return type of the constructor call (nor can you annotate the constructor return type). This is why although vm.initProxy().a works, vm.a still does not work.

To get around this limitation we have two options:

  1. Use a private constructor and a static method that is correctly typed:

    class Vue<T = {}> {
        private $options: IOptions<T> = {
            data: () => ({})
        } as IOptions<T>
    
        private constructor(options: IOptions<T>) {
            this.$options = options
            const proxy = this.initProxy()
            return proxy
        }
        static create<T>(data: IOptions<T>):Vue<T> & T {
            return new Vue<T>(data) as unknown as Vue<T> & T 
        }
        initProxy(): T & Vue<T> {
            const data = this.$options.data ? this.$options.data() : {}
    
            return new Proxy(this as unknown as T & Vue<T>, {
                set(_, key: string, value) {
                    data[key] = value
                    return true
                },
                get(_, key: string) {
                    return data[key]
                }
            })
        }
    }
    
    
    const vm = Vue.create({
        data() {
            return {
                a: 1
            }
        }
    })
    vm.a = 2;
    
  2. Use a separate signature for the class

    class _Vue<T = {}> {
        private $options: IOptions<T> = {
            data: () => ({})
        } as IOptions<T>
    
        private constructor(options: IOptions<T>) {
            this.$options = options
            const proxy = this.initProxy()
            return proxy
        }
        initProxy(): Vue<T> {
            const data = this.$options.data ? this.$options.data() : {}
    
            return new Proxy(this as unknown as Vue<T>, {
                set(_, key: string, value) {
                    data[key] = value
                    return true
                },
                get(_, key: string) {
                    return data[key]
                }
            })
        }
    }
    type Vue<T> = _Vue<T> & T
    const Vue: new<T>(data: IOptions<T>) => Vue<T> = _Vue as any
    
    const vm = new Vue({
        data() {
            return {
                a: 1
            }
        }
    })
    vm.a = 2;
    

Upvotes: 2

Ben Smith
Ben Smith

Reputation: 20230

Your Vue class is returning a Proxy object.

Your proxy has a get and set function, which means that you can set and get the wrapped object (in your case Record<string, any>) using an index operator i.e. []

So to correctly use your Vue object to add property "a" and then retrieve its value you would use:

vm["a"] = 2
console.log(vm["a"])

You can see this working here.

Upvotes: 0

t.schoel
t.schoel

Reputation: 36

Typescript does not know about the Proxy and the property names it might accept. For example, consider a setter such as:

set(_, key: string, value: any) {
  if (!key.startsWith('foo')) {
    return false;
  }

  data[key] = value;
  return true;
}

Typescript would have to run the code to determine which property names are legal here.

A quick fix for your problem would be to add a property like [key: string]: unknown; to the Vue class which will tell typescript to accept anything as long as the key is a string, regardless of its type. This will make your example compile.

You should probably consider properly declaring the properties that the Vue class will be using, though, if you possibly can, to take advantage of Typescript's static type checking.

Upvotes: 0

Related Questions