Typing refs, computed and inject
Let inference type most refs, and annotate the few places it cannot see, such as empty arrays, nulls, template refs, DOM events and injected values.
What you'll learn
When you finish, you can do these.- 1Write a type only for refs TypeScript cannot work out
- 2Get a typed element or child component from a template ref
- 3Read an input's text from a DOM event with a cast
- 4Share a typed theme with an InjectionKey and inject()
Picture a search box that filters five tool names: type vi, and only vite, vitest and vitepress are left. TypeScript works out almost every type in its code by looking at starting values. It gets stuck only where a starting value says nothing about later ones: a list of results that starts empty, an input element that does not exist yet, a typing event, and a colour theme handed down from the page's top component.
The Code tab holds that page: App.vue, the top component, keeps the theme and shows the search box from SearchBox.vue, and keys.ts holds the theme's type. By the end you can say why each type in it is worked out or written by hand. You need one idea from the lesson Typing props, emits and models: vue-tsc, a command that checks a Vue project's types, catches mistakes the browser lets through. The Code tab runs no type checker, so you predict vue-tsc's messages yourself.
Let inference work, annotate the empty start
Working out a type from the first value is called inference, and writing the type yourself is called annotating. The top of the script in SearchBox.vue shows both:
const tools = ['vue', 'vite', 'vitest', 'vitepress', 'pinia']
const query = ref('') // Ref<string>, inferred
const results = ref<string[]>([]) // empty: say what goes in
const count = computed(() => results.value.length) // ComputedRef<number>
query, the text typed into the search, is a ref: it holds a value and tells Vue when it changes. It starts as text, a string, so it is a Ref<string>. count, the number of results, is computed from results, and its function returns a number, so it is a ComputedRef<number>.
An empty array is the exception. ref([]) has no items to learn from, so TypeScript infers never[]: an array that can hold nothing. In ref<string[]>([]), the type between the angle brackets says what goes in: string[], a list of strings.
A ref that starts as null, meaning no value yet, has the same problem: ref(null) stays a Ref<null> forever. For a user who loads later, write ref<User | null>(null): a User, a type you wrote, or null.
Template refs and DOM events
Two values come from the page: the input element, whose type the template gives, and the typing event, whose type you give.
To put the cursor in the box, the script needs the input element. useTemplateRef('input') returns a template ref, a ref holding the element marked ref="input" in the template. With Vue 3.5 and the Vue - Official editor extension, the type comes from the template too: ref="input" sits on an <input>, so the ref holds HTMLInputElement | null.
// Inferred from ref="input" below: HTMLInputElement | null
const input = useTemplateRef('input')
defineExpose({ focus: () => input.value?.focus() })
The ref holds null until Vue puts the component on the page, which is called mounting. So the call is written input.value?.focus(): the ?. calls focus() only when there is an element.
defineExpose() hands this focus function to the parent, the component that shows SearchBox. In App.vue, useTemplateRef('box') holds the search box marked <SearchBox ref="box" />, and the Focus the search button calls box?.focus(). The ref's type lists only what SearchBox exposes. Older code writes that type out as useTemplateRef<InstanceType<typeof SearchBox>>('box').
Calling anything else, such as box?.clear(), fails in vue-tsc and in the browser: Try it 1 shows both.
The typing event needs a cast, where you state a type yourself. Each time you type, the input calls onInput(e: Event), and e.target is the element the event came from. Its type is EventTarget | null, because a DOM event, one of the browser's own, can come from any element. So reading e.target.value gives two vue-tsc errors: 'e.target' is possibly 'null'. and Property 'value' does not exist on type 'EventTarget'.
You know the listener sits on the input, so say so with as:
function onInput(e: Event) {
query.value = (e.target as HTMLInputElement).value
results.value = query.value ? tools.filter(t => t.includes(query.value.toLowerCase())) : []
}
Type vi, and the line under the box lists vite, vitest and vitepress. as checks nothing at runtime, so use it only where you know more than TypeScript.
Typed provide and inject
The search box takes its colour theme, Ocean or Amber, from App.vue, whose Swap theme button switches between them. App hands the theme down with provide(key, value), and SearchBox reads it with inject(key) (Provide and inject, in the Advanced track, teaches both). The key, which both import from keys.ts, is how SearchBox finds the value, and it carries the type.
keys.ts holds the theme's type and the key:
export interface Theme {
name: string
accent: string
}
export const themeKey: InjectionKey<Readonly<Ref<Theme>>> = Symbol('theme')
InjectionKey<T> is a Symbol, a unique value, with a type attached. Here the type is Readonly<Ref<Theme>>, a ref holding a Theme that SearchBox can read but not assign: what App's readonly(theme) returns. App's provide() is checked against it, so provide(themeKey, 'dark') fails with Argument of type 'string' is not assignable to parameter of type 'Readonly<Ref<Theme, Theme>>'.
What inject() returns depends on how you call it:
inject('theme'), with a string key, isunknown: anything could have been provided.inject(themeKey)isReadonly<Ref<Theme>> | undefined, because no component above may have provided it.inject(themeKey, fallback)isReadonly<Ref<Theme>>. The default fills the gap, soundefinedis gone.
SearchBox uses the third form, with a plain gray default, and draws its border in the theme's accent colour:
const theme = inject(themeKey, ref({ name: 'Plain', accent: 'gray' }))
const border = computed(() => `2px solid ${theme.value.accent}`)
Click Swap theme: the blue border turns amber, and the line under the box starts Theme: Amber.
What goes wrong
Each mistake gets past one checker: vue-tsc catches the first, the browser the second.
The first mistake starts the results list with a bare empty array.
const results = ref([])
The page still works, because JavaScript does not care. vue-tsc rejects the filtered list: Type 'string[]' is not assignable to type 'never[]'. Say what goes in, and the error is gone:
const results = ref<string[]>([])
The second mistake focuses the input straight from the script and silences the null:
const input = useTemplateRef('input')
input.value!.focus()
vue-tsc reports 'input.value' is possibly 'null'., and the !, called a non-null assertion, tells it to stop. But <script setup> runs before the input exists, so the value is in fact null. The search box never appears, and the preview shows Cannot read properties of null (reading 'focus').
Read a template ref in code that runs after mounting, such as the exposed focus() or onMounted(), and keep the ?.:
const input = useTemplateRef('input')
defineExpose({ focus: () => input.value?.focus() })
The search box is back, and Focus the search puts the cursor in it.
What to remember
- Let TypeScript infer refs and computed values. Annotate only when the first value says nothing:
ref<string[]>([]),ref<User | null>(null). useTemplateRef('name')takes its type from the template,nullincluded, because the element exists only after mounting.- On a component ref, only what the child lists in
defineExpose()exists, in the types and in the browser. e.targetis anEventTarget | null. Cast it withas HTMLInputElementwhere you know which element it is.- Type a provide with
InjectionKey<T>.inject(key)returnsT | undefined, andinject(key, fallback)returnsT.
Try it
Open the Code tab and change the starter code. Each task says what you should see.- In App.vue, replace the Try it 1 comment with
<button @click="box?.clear()">Clear</button>. Predict vue-tsc's message,Property 'clear' does not exist on type …, then click Clear: the preview showsCannot read properties of undefined (reading 'call'), the playground's wording forbox.value?.clear is not a function. In SearchBox.vue, add aclear()function that setsquery.value = ''andresults.value = [], and list it indefineExpose(). Type vi in the box and click Clear: the box and the results empty. - In App.vue, comment out
provide(themeKey, readonly(theme)). The border turns gray and the line reads Theme: Plain, becauseinject()used its default. Swap theme no longer changes the box. - Keep
providecommented out and change SearchBox's line toinject(themeKey), with no default. Predict vue-tsc's message,'theme' is possibly 'undefined'., then look at the preview: the search box disappears and the preview showsCannot read properties of undefined (reading 'value'). Put the default and theprovideback.