Skip to content

Instantly share code, notes, and snippets.

@LinusBorg
Last active November 1, 2021 08:35
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save LinusBorg/e041ff635994b50b7cec9383c3a067f1 to your computer and use it in GitHub Desktop.
Save LinusBorg/e041ff635994b50b7cec9383c3a067f1 to your computer and use it in GitHub Desktop.
Vue 3 Typescript Caveats

About this collection

This gist is a work in progress. It's goal is to collect information about possible pitfalls when using Vue 3's proxy-base reactivity and Typescript, including solutions/remedies/workarounds.

Ultimately, the goal is to turn this into a part of the Vue documentation.

TOC

  1. Leaking this from the constructor, i.e. in async operations.
  2. ref()/reactive() unwrapping loses private fields type information
  3. ref()/reactive() unwrapping and Generics

TODO/collection of cases:

vuejs/core#1930

Leaking this from the constructor, i.e. in async operations.

// Add initial explanation

Example

class Person {
  name: string
  
  constrcutor(name: string) {
    this.name = 'Default' // fine
    setTimeout(() => {
      this.name = name // this won't be reactive
    }, 0)
  }
}

const person = reactive(new Person('Tom')) // using `ref()` will have the same problem/effect

Explanation

reactive() returns a new Proxy for the Person instance, and reactivity is only provided for any chnages that happen through the proxy.

Since the constructor will run before reacive_() can even create the proxy, this`` will still refer to the raw oriignal instance.

And any change to the raw, original instance, as opposed to the proxy, will not be reactive.

Solution

If you control the class, and want it to be reactive

Start the async behaviour from a method that you call after reactive() as been applied.

class Person {
  name: string
  
  constrcutor(name: string) {
    this.name = 'Default' // fine
  }
  
  init() {
    setTimeout(() => {
      this.name = name // this won't be reactive
    }, 0)
  }
}

const person = reactive(new Person('Tom'))

person.init()

Since this change will happen through the proxy person, this time the change in the timeout callback will be reactive as this now refers to the proxy, not the raw instance.

If you can't control the code of the class / don't need it to be reactive

This is usually true for external dependencies.

Here, the solution is to either not use reactive() at all, or - when nesting the class instance in reactive state -
use markRaw() to tell Vue not to create a proxy object for this instance:

import { markRaw, reactive } from 'vue'

class Person {
  name: string
  
  constrcutor(name: string) {
    this.name = 'Default' // fine
    setTimeout(() => {
      this.name = name // this won't be reactive
    }, 0)
  }
}

const state =  reactive({
  person: markRaw(new Person('Tom'))
})

// you also need to apply this when you set `state.person` to a new value:

state.person = markRaw(new Person('Jerry'))

However, this means that mutating the class instance will not be reactive at all. In most scenarios with classes from external dependencies, that is a good idea anyway.

ref()/reactive() unwrapping loses private fields type information

Take a class definition like this one:

class Person {
class Person {
  name: string
  private secrect: string
  
  constrcutor(name: string) {
    this.name = name
  }
  
  setSecret(secret: string) {
    this.secret = secret
  },
  getSecret() {
    return this.secret
  }
}

When creating an instance of this class, the variable will be of type Person, which does include the private field. However, the Unwraping behaviour of ref and reactive can'T pick that private field up, and consequently, the type returned by ref/reactive no longer matches he original Person class:

const person = new Person('Tom') // => type is `Person`

const otherPerson = reactive(new Person('Jerry')) // type is `UnwrapRef<Person>`
/**
  UnwrapRef<Person> turns `Person` into an interface with this shape;
  
  {
    name: string
    setSecret(secret: string)
    getSecret(): string
  }
  
**/

This seems fine, but when you i.e. try and pass this object to to funtion that expects an instance of Person, Typescript will complain:

const person = new Person('Tom')
const otherPerson = reactive(new Person('Jerry'))

function handlePerson(person: Person) { /*...*/ }

handlePerson(person) // => fine
handlePerson(otherPerson) // => "missing property `private secret: string` on ...."


// this will also fail:
const otherPerson: Person = reactive(new Person('Jerry'))

This means we will have to typecast:

const otherPerson = reactive(new Person('Jerry')) as Person

Unwrap and Generics

ref()/reactive() do something that's called "unwrapping".

When using a ref, its value has to be accessed via the .value property:

const switch: Ref<boolean> = ref(false)

// get
switch.value // => false
// set
switch.value = true

But when a ref is nested in a reactive object, it's value is unwrapped as a convenience, so using the .value property is not necessary:

interface State {
   switch: boolean
}
const state: State = reactive({
  switch: ref(false)
})

state.switch // false
state.switch = true // successfully sets the new value on the nested ref.

Note how the interface doesn't declare a nested Ref - the typings unwrao the value nested in the ref. This is straightforward for simple types, but can create some pitfalls when working with generic types.

// TODO: add examples for pitfalls and mitigation startegies.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment