skip to content

Typing Refs & Injection

Typing reactive state and wiring: ref<T>() and Ref, reactive() by annotation, computed<T>, template refs, InjectionKey for provide/inject, and event handlers. Interviewers probe inference gaps.

part ofVue.jsoverview, primer and where to startread it →
on this pageshow

explore

questions

6

In Vue 3 with TypeScript, how do you type a ref that may hold `string | number`, and what does `ref<number>()` without an argument produce?

level: juniorimportance: must knowfreq 60%

answer

  1. inference comes from the initial value
  2. a type argument overrides inference
  3. or annotate with the Ref type
  4. no initial value adds undefined

basics

~20 s

Pass a type argument, ref<string | number>('2020'), or annotate the variable as Ref<string | number>. Without either, the type is inferred from the initial value only. ref<number>() with no argument is typed Ref<number | undefined>.

solid answer

~40 s

`ref()` infers its type from the initial value, so `ref(2020)` is `Ref<number>` and `year.value = '2020'` is a type error. To widen it, either pass a type argument, `const year = ref<string | number>('2020')`, or annotate the variable with the exported `Ref` type, `const year: Ref<string | number> = ref('2020')`. Both give a ref whose `.value` accepts either type. If you pass a type argument but no initial value, `ref<number>()`, the overload returns `Ref<number | undefined>`, because the value really is `undefined` until you assign it; code reading `.value` must handle that. The same inference-first rule applies to `computed()`: its type comes from the getter's return value, and `computed<number>(() => ...)` makes a wrong return a type error.

code

ts · 13 lines
ts
import { ref, computed } from 'vue'
import type { Ref } from 'vue'

interface User { id: number; name: string }

const year = ref<string | number>('2020')
const label: Ref<string> = ref('draft')
const user = ref<User | null>(null)
const page = ref<number>() // Ref<number | undefined>

const greeting = computed<string>(() =>
  user.value ? `Hi ${user.value.name}` : 'Hi guest'
)

go deeper

for a junior

Recall that ref infers from its initial value, that ref<T>() or a Ref<T> annotation widens it, and that no initial value adds undefined.

for a middle

Explain why ref(null) and ref([]) need type arguments, and how computed infers from its getter and is readonly.

for a senior

Recognise that the declared return type of ref is written with UnwrapRef because nested refs unwrap, and when that surfaces in reusable code.

for a principal

Set team conventions: null over undefined for unloaded state, no non-null assertions on refs, explicit types on shared composable signatures.

## Inference first In Vue 3, **`ref()`** creates a reactive container whose current value lives in `.value`. With TypeScript, the type of that container is **inferred from the initial value** you pass: ```ts import { ref } from 'vue' const year = ref(2020) // Ref<number> year.value = 2021 // ok year.value = '2020' // error: string is not assignable to number ``` For most refs this is all you need: the initial value already says what the ref holds. Explicit typing matters when the initial value is **narrower than the values the ref will hold later**, or when there is **no meaningful initial value** at all. ## Two ways to widen the type | Technique | Example | Resulting type | |---|---|---| | Type argument | `ref<string \| number>('2020')` | `Ref<string \| number>` | | Variable annotation | `const year: Ref<string \| number> = ref('2020')` | `Ref<string \| number>` | | Plain inference | `ref('2020')` | `Ref<string>` | - The **type argument** is the most common style: it keeps the declaration on one line and reads like the value it guards. - The **annotation** uses the `Ref` type imported with `import type { Ref } from 'vue'`. It is useful when a function parameter or return type must be a ref, for example a composable accepting `Ref<string>`. - In Vue 3.5 the `Ref` interface also has a second parameter for the **write type**, `Ref<T, S = T>`; in everyday code you only write the first. ## `ref<T>()` without an initial value `ref()` has an overload for the no-argument call: `ref<T = any>(): Ref<T | undefined>`. So: ```ts const n = ref<number>() // Ref<number | undefined> n.value?.toFixed(2) // must handle undefined ``` That `undefined` is not TypeScript being pedantic: the value genuinely is `undefined` until something assigns it. Common responses: 1. **Give a real initial value** (`ref(0)`, `ref<User | null>(null)`) when one exists; `null` communicates "not loaded yet" more deliberately than `undefined`. 2. **Narrow before use**: optional chaining, an `if` guard, or a `v-if` in the template. 3. **Avoid non-null assertions** (`n.value!`) unless the logic truly guarantees assignment, because they silence the check without making it true. ## The same rule for `computed()` `computed()` infers a `ComputedRef<T>` from the getter's return type, `computed(() => count.value * 2)` being `ComputedRef<number>`. An explicit argument, `computed<number>(() => ...)`, turns a getter that returns the wrong type into a type error at its definition instead of at every use. A getter-only `ComputedRef` exposes `value` as **readonly**, so assigning to it is a type error; a writable computed takes `{ get, set }` and returns a `WritableComputedRef`. ## Nested refs and unwrapping A ref holding an object makes that object deeply reactive, and **nested refs inside it are unwrapped** in the type as well as at runtime. `ref({ count: ref(0) })` gives `.value.count` as `number`, not `Ref<number>`. That is why the declared return type of `ref()` is written in terms of an `UnwrapRef` helper rather than plain `T`; with concrete types TypeScript evaluates it to the shape you expect, so everyday code never sees it. ## Refs in function signatures The `Ref` type earns its place at **boundaries**, where a value crosses from one function to another. A composable that must react to changes needs a ref, not a snapshot of the value: ```ts import { watch, type Ref } from 'vue' export function useDocumentTitle(title: Ref<string>) { watch(title, (t) => { document.title = t }, { immediate: true }) } ``` - Calling `useDocumentTitle(ref('Home'))` type-checks. - Calling `useDocumentTitle('Home')` is a **type error**: a plain string has no `.value` and would never change, so the signature catches a real bug. - Returning refs from a composable works the same way: an explicit return type such as `{ count: Ref<number> }` documents the contract for every caller and stops an accidental change of type inside the function from leaking out. Inside a single component, inference from the initial value is normally enough; explicit `Ref<T>` annotations pay off on these shared signatures. ## Common mistakes - Writing `ref(null)` for a value that will later be a `User`: inference gives `Ref<null>`, and every later assignment fails. Use `ref<User | null>(null)`. - Writing `ref([])` for a list: an empty array literal gives TypeScript no element type (under strict settings it infers an array of `never`), so pushing an item fails; use `ref<Item[]>([])`. - Annotating with `Ref<number>` but passing a string initial value: the annotation is checked, so it errors. - Reading `.value` of `ref<T>()` as if it were `T`.

  • In Vue with TypeScript, why is `const user = ref(null)` a trap for a value that later holds a User?
    Inference uses the initial value, so the ref is typed `Ref<null>` and assigning a `User` later is a type error. Declare the eventual type up front with `ref<User | null>(null)`, which also documents that `null` means not loaded yet.
  • In Vue 3, can you assign to the value of `computed(() => a.value + 1)`?
    No. A getter-only computed returns a `ComputedRef<T>` whose `value` is readonly, so the assignment is a type error (and a warning at runtime in development). For a settable derived value, pass `{ get, set }` to get a `WritableComputedRef`.

saying these in an interview costs you the question

  • Every ref needs an explicit type argument or TypeScript types it as any.
  • ref<number>() without an initial value is typed Ref<number>.
  • ref(null) will accept a User later because null is assignable to everything.
  • The Ref annotation and the ref<T>() argument produce different types.
  • A getter-only computed can be assigned through .value like a ref.
open as a page

In a Vue 3.5 `<script setup lang="ts">` component, how do you type template refs to an `<input>` element and to a child component?

level: middleimportance: must knowfreq 55%

basics

~10 s

Use useTemplateRef: the Vue 3.5 language tools infer static refs, or you write useTemplateRef<HTMLInputElement>('input'). For a component, use InstanceType<typeof Child>. The value is null until mount, so access it with optional chaining.

open as a page

In a Vue `<script setup lang="ts">` component, how do you type a native `@change` handler so reading the input's value compiles?

level: juniorimportance: should knowfreq 40%

basics

~10 s

Annotate the parameter, function onChange(event: Event), then narrow the target: (event.target as HTMLInputElement).value, or an instanceof check. Without the annotation the parameter is implicitly any, which strict TypeScript rejects.

open as a page

In Vue 3, how do you type a cart service shared through provide/inject so the provider and every consumer agree on its type?

level: middleimportance: should knowfreq 45%

basics

~10 s

Export a typed key, const CartKey: InjectionKey<CartService> = Symbol('cart'), from one module. provide(CartKey, value) is checked against CartService, and inject(CartKey) returns CartService | undefined, which a default or a throwing useCart() helper narrows.

open as a page

In Vue 3, why does the documentation recommend `const book: Book = reactive({...})` over passing a type argument as in `reactive<Book>({...})`?

level: middleimportance: should knowfreq 30%

basics

~20 s

reactive() returns an unwrapped type, Reactive<T>, which differs from the type argument whenever the object contains refs. Annotating the variable with an interface describes the shape you actually read and write, so the docs advise annotation instead of reactive<T>().

open as a page

In a generic Vue composable `function useSelection<T>(initial: T)`, why is `ref(initial)` not assignable to `Ref<T>`, and how do you fix it?

level: seniorimportance: nice to knowfreq 20%

basics

~10 s

ref(initial) is typed Ref<UnwrapRef<T>>, because ref unwraps nested refs, and for an unresolved T TypeScript cannot prove UnwrapRef<T> equals T. Cast with as Ref<T>, or use shallowRef(initial), which is typed ShallowRef<T> exactly.

open as a page