These are the concepts you need to know when starting your Vue 3 + TypeScript journey.
Vue 3 is leaps ahead of its predecessor in ease of use and compatibility with TypeScript. However, sometimes the information on the key things to know and get started can be a little hard to digest.
In this article, I aim to explain and list the concepts that I feel are the most common and relevant to keep in mind when starting your Vue 3 + TS journey. Please note that this is not a TypeScript tutorial and I will assume basic knowledge of the language.
TypeScript tooling and set up is quite a rabbit hole and one could probably write not one, but several in-depth articles about how to set up a project for the perfect integration between the two.
However, I think there is one important gotcha that I want to mention before we get going with actual TS and that is Volar’s takeover mode.
I assume you will be using Volar and VS Code here. If you are using different tooling, you can safely skip to the next section.
Save yourself some headaches now and go through the link above for the documentation on how to set up takeover mode—it’s not obvious that one has to set up VS Code like this for Volar to work properly with TypeScript.
Once you’re done, also make sure to check that the TypeScript version that Volar is using is the same as your package. This mismatch can quickly create some headaches when running type checks in CI or outside of your personal dev environment.
You’ll want to select “Use Workspace Version” unless you are 100% sure you want a different version powering Volar than what your workspace is using.
When working with components created with the Options API, you will want to import and use the defineComponent
helper from Vue to make sure that the component is correctly typed when imported into other files.
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
})
</script>
You don’t need this when using script setup
sugar, as it will already be typed correctly for you.
Correctly typing your component props is arguably one of the most important parts of using TS with Vue 3. This will ensure type safety even within your template
tags, so how do we go about typing things?
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
props: {
someNumber: { type: Number, default: 0 },
user: { type: Object, default: 0 }
}
})
</script>
<script setup lang="ts">
defineProps({
someNumber: { type: Number, default: 0 },
user: { type: Object, default: 0 }
})
</script>
In the above example, we have both Options API and Composition API defining the exact same props without any special kind of TS types. They both create a someNumber
prop which is typed to a Number
and a user
typed to a generic Object
.
When dealing with primitives like number
, boolean
, string
, we don’t really need to do anything to tell TypeScript the type of the prop as it will be inferred by the type
that Vue provides, like in the case of someNumber
.
However, we probably want to use a more specific type for our user prop.
I will assume we have the following type defined:
interface User {
id: number
name: string
}
Now, we can use the special type PropType
that Vue provides to more specifically define what that Object
in user
is.
<script lang="ts">
import { defineComponent, PropType } from 'vue'
export default defineComponent({
props: {
someNumber: { type: Number, default: 0 },
user: { type: Object as PropType<User>, default: 0 }
}
})
</script>
<script setup lang="ts">
import { PropType } from 'vue'
defineProps({
someNumber: { type: Number, default: 0 },
user: { type: Object as PropType<User>, default: 0 }
})
</script>
Now that we have props correctly typed, we want to make sure that our emits
are also strictly typed so that Volar and TypeScript can help us check that we are using emit payloads correctly.
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
emits: ['userSelected', 'click']
})
</script>
<script setup lang="ts">
defineEmits(['userSelected', 'click'])
</script>
In the above example, we see again both Options API and Composition API examples of how to define emits
in a component. We are choosing to use the shorthand version of emits (there is a longer version that allows for validating the output similar to how props work).
The problem with leaving it like this when using TypeScript is that the content of the emit
will be assumed to be any
, which is not very helpful. Let’s go ahead and type these emits correctly.
We will assume that the click
event is not emitting a payload, and the userSelected
will pass a user object.
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
emits: {
// eslint-disable-next-line
'userSelected': (user: User) => true,
// eslint-disable-next-line
'click': () => true
}
})
</script>
<script setup lang="ts">
defineEmits<{
'userSelected': [user: User]
'click': []
}>()
</script>
There’s a couple things worth unwrapping in this example. Let’s start with the Options API.
The emits
property is now an object, instead of an array of strings. This is the extended way of defining emits, where each emit declares a function that works as a validation, similar to the validator
property in props
. I’ve opted to return true
because we don’t need to validate the emit, since TypeScript is probably validation enough—this is up to you entirely.
The parameter found inside the validation function will be assumed by TypeScript to be the payload of the emit
, so now when we have a component listening for the userSelected
event, TypeScript will know we are expecting a User
.
Note that I’ve added a disable on ESLint on both lines. Depending on your ESLint rules, this may not be necessary, but with the default Vue recommended rules you will get an error on the function since the user
param is not being used. We don’t need it for click
technically, but it’s become a habit of mine to add it everywhere to avoid headaches.
The Composition API version is also a bit different. Notice that the actual declaration of the emits is no longer a param inside the defineEmits
function, but rather a type variable. So now it lives within the <{}>
before the ()
.
We define our emits by setting a property like userSelected
and then with a tuple syntax we determine the payload. So that [user: User]
means we will have a user
payload with a User
type. Once again this will allow TypeScript to determine the payload that we may expect in another component when listening to this particular event.
A few types that you are going to be using a lot (you import these out of 'vue'
) are:
const user = ref({ id: 123, name: 'Marina' })
In cases like this, you may want to strictly type your ref, so that instead of TS assuming this is an object with an id and name, it’s a User type.
const user: Ref<User> = ref({ id: 123, name: 'Marina' })
Note that Ref
can also accept ComputedRef
types, such that if you have a function where you would expect either a ref or a computed value you can safely use Ref
.
const myFn = (param: Ref) => {
return param.value // .value is defined as it exists in both computed and ref
}
unref
a lot since we don’t know if our user is going to pass in a raw value or a ref. In these cases we can use the MaybeRef
type.
export default (val: MaybeRef<boolean>) => {
const rawBool = unref(val)
}
ref
, you will want to use either ComponentPublicInstance
to type it as any generic component
.
<template>
<p ref="myP">Example</p>
<SomeComponent ref="myComp" />
</template>
<script setup lang="ts">
import { Ref } from 'vue'
const myP: Ref<HTMLElement|null> = ref(null)
const myComp: Ref<ComponentPublicInstance|null> = ref(null)
const doSomething = () => {
// TS knows about $el because its a ComponentPublicInstance
myComp.value?.$el
}
</script>
If you need to access specific methods within the component ref, however, you will have to use InstanceType
to define it instead of ComponentPublicInstance
.
<template>
<p ref="myP">Example</p>
<SomeComponent ref="myComp" />
</template>
<script setup lang="ts">
import { Ref } from 'vue'
const myP: Ref<HTMLElement|null> = ref(null)
const myComp: Ref<InstanceType<typeof SomeComponent>|null> = ref(null)
const doSomething = () => {
// TS knows about myMethod inside of `SomeComponent`
myComp.value?.myMethod()
}
</script>
When using global components that are not specifically imported into our components, we need to declare them somewhere for TypeScript to work.
Within global.d.ts
you can add the following declaration with your global components as you need. I’ve added an example with RouterLink
and RouterView
from Vue Router as they are commonly used as global components.
import BaseCheckbox from './globals/BaseCheckbox.vue'
declare module '@vue/runtime-core' {
export interface GlobalComponents {
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
// Custom components example
BaseCheckbox: typeof BaseCheckbox
}
}
This is really only scratching the surface of TS integration with Vue 3, but I hope that these must-know key topics help you get started quickly and effectively in your TS-Vue 3 journey.
Marina Mosti is a frontend web developer with over 18 years of experience in the field. She enjoys mentoring other women on JavaScript and her favorite framework, Vue, as well as writing articles and tutorials for the community. In her spare time, she enjoys playing bass, drums and video games.