skip to content

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.