With <script setup lang="ts"> and "strict": true, Vue's reactivity functions carry types from the value you give them to every place you read it. Most of the time you write no types at all. The trouble starts with values that are empty at first, values that can hold more than one type, and values that come from somewhere else in the component tree.
Each case below shows the error that vue-tsc (the TypeScript checker that understands .vue files) prints, and the fix.
Let ref infer the type, or pass a union
ref(0) is a Ref<number>; ref('') is a Ref<string>. Assign the wrong type and you get the error at the assignment, which is where you want it:
import { ref } from 'vue'
const count = ref(0)
count.value = 'ten'
// Type 'string' is not assignable to type 'number'.
When a value can legitimately hold more than one type, say an ID that arrives as a string from the URL and is stored as a number, give ref a generic argument with the union. The annotation form const id: Ref<string | number> does the same, but the generic reads better and needs no extra import.
const id = ref<string | number>('42')
id.value = 42
Handle a ref that starts empty without !
The common case: data that is not there yet. ref<User>() with no argument is typed Ref<User | undefined>. ref<User | null>(null) is typed Ref<User | null>. Pick one convention and keep it; this post uses null for "nothing loaded yet".
Either way, every read now has to deal with the empty state. This is where the "possibly null" error comes from, and the tempting fix is a !:
import { computed, ref } from 'vue'
interface User {
id: number
name: string
}
const user = ref<User | null>(null)
const initials = computed(() => user.value.name.slice(0, 2))
// 'user.value' is possibly 'null'. (18047)
The non-null assertion, user.value!.name, makes the error go away and keeps the crash: if a template reads initials before the user has loaded, the getter throws Cannot read properties of null. Say what should happen in the empty case instead. Optional chaining and a default do it in one line:
const initials = computed(() => user.value?.name.slice(0, 2) ?? '')
In templates, v-if="user" narrows the type inside the block, so {{ user.name }} under it compiles with no ?.. In functions, an early if (!user.value) return does the same for the lines that follow.
Type composable parameters with Ref or MaybeRefOrGetter
Once you write composables, functions that bundle reactive logic for reuse, the question flips: what type should a parameter have? It depends on what the composable does with the value:
- If it writes to the value, ask for a
Ref<T>. - If it only reads, accept more.
MaybeRef<T>takes a plain value or a ref, andMaybeRefOrGetter<T>also takes a getter function,() => T.
Pair either with toValue(), which unwraps whichever you got. This composable sets the page title:
import { watchEffect, toValue, type MaybeRefOrGetter } from 'vue'
export function useTitle(title: MaybeRefOrGetter<string>) {
watchEffect(() => {
document.title = toValue(title)
})
}
Callers can now pass a plain string, a ref, or a getter such as () => post.value.title. Because toValue() is called inside watchEffect, the effect tracks what the ref or getter reads and re-runs when it changes. MaybeRef, MaybeRefOrGetter and toValue() arrived in Vue 3.3, so any 3.5 project has them.
When to give computed an explicit type
computed() takes its type from what the getter returns. const double = computed(() => count.value * 2) is a ComputedRef<number>, so double.value.split('') is an error. You rarely annotate it, with two exceptions:
- A getter with several branches. An explicit
computed<Status>(() => ...)checks every value the getter returns againstStatus. A typo such as'laoding'in one branch fails on thecomputed()call itself, withType '"laoding"' is not assignable to type 'Status'in the message, rather than wherever the result is used later. - A writable computed. One with
getandsetis aWritableComputedRef<T>, the type you name when you pass it to a function that expects one.
Here the generic limits the getter to the three states of Status:
import { computed, ref } from 'vue'
type Status = 'idle' | 'loading' | 'done'
const loading = ref(false)
const loaded = ref(false)
const status = computed<Status>(() => {
if (loading.value) return 'loading'
return loaded.value ? 'done' : 'idle'
})
Type template refs with useTemplateRef
Vue 3.5 added useTemplateRef('name'), which returns a read-only shallow ref to the element or component whose ref="name" attribute matches.
When the ref attribute is static and both sides are in the same single-file component, the Vue language tools (2.1 and later, which power vue-tsc and the editor) infer the type from the element. Outside that case, or when you want the type written down, pass it as the generic, as SearchBox.vue below does. Either way the value is null until the component has mounted, and again if a v-if removes the element, so read it in onMounted or later, with ?..
<script setup lang="ts">
import { onMounted, useTemplateRef } from 'vue'
const input = useTemplateRef<HTMLInputElement>('search')
onMounted(() => {
input.value?.focus()
})
</script>
<template>
<input ref="search" type="search" placeholder="Search" />
</template>
For a child component, the type is the component's instance, which TypeScript can extract with InstanceType<typeof Modal>. Of the child's own variables and functions, only those it exposes through defineExpose are on that type, so a call to modal.value?.open() compiles only when Modal.vue exposes it with defineExpose({ open }).
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import Modal from './Modal.vue'
const modal = useTemplateRef<InstanceType<typeof Modal>>('modal')
function openSettings() {
modal.value?.open()
}
</script>
<template>
<button type="button" @click="openSettings">Settings</button>
<Modal ref="modal" />
</template>
Before 3.5 the same thing was const input = ref<HTMLInputElement | null>(null) with a matching ref="input" in the template. It still works, and you will see it in older code.
Type provide and inject with an InjectionKey
provide() and inject() pass a value down the component tree without props. With a string key, inject('theme') has type unknown, because nothing links the two ends. Vue's InjectionKey<T> is a symbol that carries the type: declare it once in a shared file, and both provide and inject check against it.
import type { InjectionKey, Ref } from 'vue'
export interface Theme {
mode: 'light' | 'dark'
}
export const themeKey = Symbol('theme') as InjectionKey<Ref<Theme>>
Start with the provider. Somewhere above the consumers, usually App.vue, a component creates the ref and provides it under the key:
<script setup lang="ts">
import { provide, ref } from 'vue'
import { themeKey, type Theme } from './keys'
const theme = ref<Theme>({ mode: 'light' })
provide(themeKey, theme)
</script>
The provider now gets an error if it provides anything but a Ref<Theme>. Provide a plain object instead of the ref and the error appears on the provide call, which is where the mistake is.
The consumer still gets Ref<Theme> | undefined from inject(themeKey), because TypeScript cannot know that an ancestor called provide. There are two honest ways to remove the undefined: throw a clear error, or pass a default (the next section). Throwing suits a component that cannot do its job without the provider, such as a form field outside a form or a tab outside a tab group. The throw also narrows the type for the rest of the function:
import { inject } from 'vue'
import { themeKey } from '../keys'
export function useTheme() {
const theme = inject(themeKey)
if (!theme) {
throw new Error('useTheme() needs a ThemeProvider above the component')
}
return theme
}
The third option, inject(themeKey) as Ref<Theme>, is a promise to the compiler that you cannot keep from inside the component. When a provider is missing, Vue only logs injection "Symbol(theme)" not found in development, and the code fails later, at the first read of .value, far from the cause. The thrown error names the fix instead.
Give inject a default when a fallback is safe
A theme is different from a form field. A component rendered on its own, in a test or in a playground, can fall back to light mode and nothing breaks. For that case inject() takes a default as its second argument, and the return type loses its undefined.
The default must match the key's type, which here is a Ref<Theme>, so the fallback is a ref too. Pass a factory and true as the third argument, so the ref is only created when no provider exists:
import { inject, ref } from 'vue'
import { themeKey, type Theme } from '../keys'
export function useTheme() {
return inject(themeKey, () => ref<Theme>({ mode: 'light' }), true)
}
The result is a Ref<Theme>, so useTheme().value.mode compiles with no ?.. Leave out the true and Vue treats the function itself as the default, which TypeScript rejects, because a function is not a Ref<Theme>. The error is a long one, but it lands on the inject line.
The provider in App.vue does not change. A default has one cost: it is a fresh ref that no provider will ever update, so a consumer that falls back to it is cut off from the rest of the app. Choose the default when a missing provider is a legitimate state, and the throw when it is a bug.