inb4: This is not another "setting up" a new project with Vue and TypeScript tutorial. Let's do some deep dive into more complex topics!
typescript
is awesome. Vue
is awesome. No doubt, that a lot of people try to bundle them together. But, due to different reasons, it is hard to really type your Vue
app. Let's find out what are the problems and what can be done to solve them (or at least minimize the impact).
TLDR
We have this wonderful template with Nuxt
, Vue
, Vuex
, and jest
fully typed. Just install it and everything will be covered for you. Go to the docs to learn more.
And as I said I am not going to guide you through the basic setup for three reasons:
- There are a lot of existing tutorials about it
- There are a lot of tools to get started with a single click like
Nuxt
andvue-cli
withtypescript
plugin - We already have
wemake-vue-template
where every bit of setup that I going to talk about is already covered
Component typings
The first broken expectation when you start to work with Vue
and typescript
and after you have already typed your class components is that <template>
and <style>
tags are still not typed. Let me show you an example:
<template>
<h1 :class="$style.headr">
Hello, {{ usr }}!
</h1>
</template>
<script lang="ts">
import Vue from 'vue'
import Component from 'vue-class-component'
import { Prop } from 'vue-property-decorator'
@Component({})
export default class HelloComponent extends Vue {
@Prop()
user!: string
}
</script>
<style module>
.header { /* ... */ }
</style>
I have made two typos here: {{ usr }}
instead of {{ user }}
and $style.headr
instead of $style.header
. Will typescript
save me from these errors? Nope, it won't.
What can be done to fix it? Well, there are several hacks.
Typing the template
One can use Vetur
with vetur.experimental.templateInterpolationService
option to type-check your templates. Yes, this is only an editor-based check and it cannot be used inside the CI yet. But, Vetur
team is working hard to provide a CLI to allow this. Track the original issue in case you are interested.
The second option is two write snapshot tests with jest
. It will catch a lot of template-based errors. And it is quite cheap in the maintenance.
So, the combination of these two tools provides you a nice Developer Experience with fast feedback and a reliable way to catch errors inside the CI.
Typing styles
Typing css-module
s is also covered by several external tools:
The main idea of these tools is to fetch css-module
s and then create .d.ts
declaration files out of them. Then your styles will be fully typed. It is still not implemented for Nuxt
or Vue
, but you can tract this issue for progress.
However, I don't personally use any of these tools in my projects. They might be useful for projects with large code bases and a lot of styles, but I am fine with just snapshots.
Styleguides with visual regression tests also help a lot. @storybook/addon-storyshots
is a nice example of this technique.
Vuex
The next big thing is Vuex
. It has some built-in by-design complexity for typing:
const result: Promise<number> = this.$store.dispatch('action_name', { payload: 1 })
The problem is that 'action_name'
might no exist, take other arguments, or return a different type. That's not something you expect for a fully-typed app.
What are the existing solutions?
vuex-class
vuex-class
is a set of decorators to allow easy access from your class-based components to the Vuex
internals.
But, it is not typed safe since it cannot interfere with the types of state, getters, mutations, and actions.
Of course, you can manually annotate types of properties.
But what are you going to do when the real type of your state, getters, mutations, or actions will change? You will have a hidden type mismatch.
vuex-simple
That's where vuex-simple
helps us. It actually offers a completely different way to write your Vuex
code and that's what makes it type safe. Let's have a look:
import { Action, Mutation, State, Getter } from 'vuex-simple'
class MyStore {
// State
@State()
public comments: CommentType[] = []
// Getters
@Getter()
public get hasComments (): boolean {
return Boolean(this.comments && this.comments.length > 0)
}
// Mutations
@Mutation()
public setComments (payload: CommentType[]): void {
this.comments = updatedComments
}
// Actions
@Action()
public async fetchComments (): Promise<CommentType[]> {
// Calling some API:
const commentsList = await api.fetchComments()
this.setComments(commentsList) // typed mutation
return commentsList
}
}
Later this typed module can be registered inside your Vuex
like so:
import Vue from 'vue'
import Vuex from 'vuex'
import { createVuexStore } from 'vuex-simple'
import { MyStore } from './store'
Vue.use(Vuex)
// Creates our typed module instance:
const instance = new MyStore()
// Returns valid Vuex.Store instance:
export default createVuexStore(instance)
Now we have a 100% native Vuex.Store
instance and all the type information bundled with it. To use this typed store in the component we can write just one line of code:
import Vue from 'vue'
import Component from 'nuxt-class-component'
import { useStore } from 'vuex-simple'
import MyStore from './store'
@Component({})
export default class MyComponent extends Vue {
// That's all we need!
typedStore: MyStore = useStore(this.$store)
// Demo: will be typed as `Comment[]`:
comments = typedStore.comments
}
Now we have typed Vuex
that can be safely used inside our project.
When we change something inside our store definition it is automatically reflected to the components that use this store. If something fails — we know it as soon as possible.
There are also different libraries that do the same but have different API. Choose what suits you best.
API calls
When we have Vuex
correctly setup, we need to fill it with data.
Let's have a look at our action definition again:
@Action()
public async fetchComments (): Promise<CommentType[]> {
// Calling some API:
const commentsList = await api.fetchComments()
// ...
return commentsList
}
How can we know that it will really return a list of CommentType
and not a single number
or a bunch of AuthorType
instances?
We cannot control the server. And the server might actually break the contract. Or we can simply pass the wrong api
instance, make a typo in the URL, or whatever.
How can we be safe? We can use runtime typing! Let me introduce io-ts
to you:
import * as ts from 'io-ts'
export const Comment = ts.type({
'id': ts.number,
'body': ts.string,
'email': ts.string,
})
// Static TypeScript type, that can be used as a regular `type`:
export type CommentType = ts.TypeOf<typeof Comment>
What do we do here?
- We define an instance of
ts.type
with fields that we need to be checked in runtime when we receive a response from server - We define a static type to be used in annotation without any extra boilerplate
And later we can use it our api
calls:
import * as ts from 'io-ts'
import * as tPromise from 'io-ts-promise'
public async fetchComments (): Promise<CommentType[]> {
const response = await axios.get('comments')
return tPromise.decode(ts.array(Comment), response.data)
}
With the help of io-ts-promise
, we can return a Promise
in a failed state if the response from server does not match a ts.array(Comment)
type. It really works like a validation.
fetchComments()
.then((data) => /* ... */
.catch(/* Happens with both request failure and incorrect response type */)
Moreover, return type annotation is in sync with the .decode
method. And you cannot put random nonsense there:
With the combination of runtime and static checks, we can be sure that our requests won't fail because of the type mismatch.
But, to be 100% sure that everything works, I would recommend using contract-based testing: have a look at pact
as an example. And monitor your app with Sentry
.
Vue Router
The next problem is that this.$router.push({ name: 'wrong!' })
does not work the way we want to.
I would say that it would be ideal to be warned by the compiler that we are routing to the wrong direction and this route does not exist.
But, it is not possible. And not much can be done: there are a lot of dynamic routes, regex, fallbacks, permissions, etc that can eventually break. The only option is to test each this.$router
call in your app.
vue-test-utils
Speaking about tests I do not have any excuses not to mention @vue/test-utils
that also has some problems with typing.
When we will try to test our new shiny component with typedStore
property, we will find out that we actually cannot do that according to the typescript
:
Why does this happen? It happens because mount()
call does not know anything about your component's type, because all components have a VueConstructor<Vue>
type by default:
That's where all the problems come from. What can be done?
You can use vuetype
to produce YouComponent.vue.d.ts
typings that will tell your tests the exact type of the mounted component.
You can also track this issue for the progress.
But, I don't like this idea. These are tests, they can fail. No big deal.
That's why I stick to (wrapper.vm as any).whatever
approach. This saves me quite a lot of time to write tests. But spoils Developer Experience a little bit.
Make your own decision here:
- Use
vuetype
all the way - Partially apply it to the most important components with the biggest amount of tests and update it regularly
- Use
any
as a fallback
Conclusion
The average level of typescript
support in Vue
ecosystem increased over the last couple of years:
Nuxt
firstly introducednuxt-ts
and now shipsts
builds by defaultVue@3
will have improvedtypescript
support- More 3rd-party apps and plugins will provide type definitions
But, it is production ready at the moment. These are just things to improve! Writing type-safe Vue
code really improves your Developer Experience and allows you to focus on the important stuff while leaving the heavy-lifting to the compiler.
What are your favourite hacks and tools to type Vue
apps? Let's discuss it in the comment section.