Vue Components + TypeScript
Using TypeScript to define Vue components may, at times, not feel as smooth as we’d like. This will very likely change with Vue3, which has been itself rewritten using this wonderful language, but for our projects still using Vue2, this post describes a way for you to get your components types right 100% of the times.
The Problem
Has it ever happened to you that VSCode complained about a Vuex getter not being part of your component? Or about a property you mixed-in not being there? We see this in our projects more often than not.
In the image above, we’re mixing CountMixin
in our component. This is a very simple mixin defined as follows:
// Count.mixin.tsimport Vue from 'vue'
import { mapGetters } from 'vuex'export default Vue.mixin({
computed: {
...mapGetters(['count'])
}
})
This mixin adds the count
getter as a computed property to components, still VSCode (with Vetur plugin installed to help us) complains about it not being defined in the component. 😖🤷🏽♀️
You may have seen this same error in different forms and situations, but the problem always boils down to the way Vue infers the options in your component: data
, computed
, methods
and props
.
How do we go about fixing these annoying errors?
The Fix: Solution #1
Once we learn about the generic types used by Vue.extend
to infer your component’s options, this actually becomes a breeze to fix (except in a few situations where things may get trickier, but that’s for another day).
Here’s how the Vue.extend
function signature is defined (with some details elided):
extend<Data, Methods, Computed, Props>(
options?: ...
): ExtendedVue<V, Data, Methods, Computed, Props>;
In bold are the four generic types the extend
function uses: Data
, Methods
, Computed
and Props
. This function is usually able to figure those out for you, and it makes a great job in most cases. But if it gets confused about them, which happens in a couple of cases like when you use a mixin, you just need to give Vue type inference a hand.
We can define our component types for the computed
and method
options like this:
// Count.vueinterface Computed {
count: number
}interface Methods {
incrementCount: Function
decrementCount: Function
}
We don’t need to worry about data
and props
here, since our component uses neither. We may now use these interfaces in our component:
// Count.vueexport default Vue.extend<{}, Methods, Computed, {}>({
mixins: [CountMixin],
methods: {
...mapActions(['incrementCount', 'decrementCount'])
}
})
And with this, we’re telling the Vue.extend
function in charge of defining our component what are the exact types our component uses for the methods
and the computed
options. We could have also included types for Data
and Props
, but since in our case they’re empty, we used an empty interface for them: {}
.
Note that if you decide to add the types yourself, you then need to add all of them, not only those that Vetur was complaining about in the first place.
Improving The Solution
What happens when we use several mixins? As well as using the Vuex store with some getters and actions? Well, nothing changes, we’d still define the types covering everything in our component’s options.
But you may find yourself repeating lots of types for different components using the same mixin, for instance. What can we do about it? Very simple: declare the mixin typings inside the mixin itself and import them in your components. Using TypeScript intersection types you can very easily compose types for your components.
Let’s see a concrete example. Imagine we have the mixin we used earlier: Count.mixin.ts, we’d declare the types for it as follows:
// Count.mixin.tsimport Vue from 'vue'
import { mapGetters } from 'vuex'export interface Computed {
count: number
}export default Vue.mixin({
computed: {
...mapGetters(['count'])
}
})
Note how we also typed the mixin using the Computed
interface. Now say we implement another mixin that declares a computed property coming from Vuex to know when the count is loading:
// Loading.mixin.tsexport interface Computed {
loading: boolean
}export default Vue.mixin({
computed: {
...mapGetters(['loading'])
}
})
Now you want to use these mixins in your component. You could define the types for them like so:
// Count.vueimport
CountMixin,
{ Computed as CountComputed }
from './Count.mixin'import
LoadingMixin, { Computed as LoadingComputed }
from './Loading.mixin'type Computed = CountComputed & LoadingComputedinterface Methods {
incrementCount: Function
decrementCount: Function
}export default Vue.extend<{}, Methods, Computed, {}>({
mixins: [CountMixin, LoadingMixin], methods: {
...mapActions(['incrementCount', 'decrementCount'])
}
})
Yes, I know, I know… We don’t like to mix default and non-default exports in the same module. Indeed, but allow me to do it here for the sake of example (besides, I myself don’t have strong arguments against them and use them quite frequently).
See how simple it becomes to get our component options types right all the time in a reusable and composable manner? Using a TypeScript intersection type: CountComputed & LoadingComputed
, we effectively define our component’s computed
option in a very elegant way.
I hope this helps because it took me some trial and error to get it right. But since I found this solution, I can’t stop using strong types in all of my Vue components.
The Fix: Solution #2
This solution was spotted in the wild by one of our engineers some time ago and we’ve used it in our Glovo applications for a long time now. It also works like a charm.
In this solution, we don’t need to type everything inside our component, but simply the parts that Vue is missing. This solution can, therefore, be less verbose. We need to do a trick with a somewhat complicated syntax, but once you get the grasp of it, it’s also quite simple:
// Count.vueimport Vue, { VueConstructor } from 'vue'interface CountBindings extends Vue {
count: number
}export default (Vue as VueConstructor<CountBindings>).extend({
mixins: [CountMixin],
methods: {
...mapActions(['incrementCount', 'decrementCount'])
}
})
And our component gets all the types right this time. In this case, we extend from Vue
making a cast of it to VueConstructor
(you can think of this guy as being the component’s base type) with the generic type set to our interface: VueComponent<CountBindings>
.
Have you found other ways of getting the types of your Vue components right? Please share them with us in the comments :)