skip to content

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%

answer

  1. ref's return type is not Ref<T>
  2. deep unwrapping of nested refs
  3. a conditional type stays deferred
  4. shallowRef keeps the type exact
  5. the cast Vue's own type tests use

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.

solid answer

~40 s

In Vue 3.5, `ref(value)` is declared to return `Ref<UnwrapRef<T>, UnwrapRef<T> | T>`: a ref deep-unwraps any refs nested in its value, and `UnwrapRef` models that. With a concrete type TypeScript evaluates `UnwrapRef<User>` to `User`, so everyday code never notices. Inside a generic composable, `T` is unresolved, the conditional type stays deferred, and TypeScript cannot prove `UnwrapRef<T>` is `T`, so `const current: Ref<T> = ref(initial)`, or returning it where `Ref<T>` is declared, fails. Reading still works through `T`'s constraint, and 3.5's separate write type accepts a `T` on assignment. Fixes: `ref(initial) as Ref<T>`, the cast Vue's own type tests use, honest when `T` will not contain refs; or `shallowRef(initial)`, typed exactly `ShallowRef<T>`, which also skips deep reactivity, fine for a selection replaced as a whole.

go deeper

for a junior

Recall that ref unwraps refs nested inside its value, which is why its declared type mentions UnwrapRef.

for a middle

Explain that concrete types evaluate UnwrapRef away, while a generic T leaves it deferred.

for a senior

Diagnose the Ref<T> assignment error in generic composables and choose between a cast and shallowRef based on whether the value is mutated in place.

for a principal

Set conventions for shared composable signatures, trading exact UnwrapRef types against readable Ref<T> contracts for consumers.

## The symptom A reusable composable is written generically so each caller keeps its own item type: ```ts import { ref, type Ref } from 'vue' export function useSelection<T>(initial: T): { current: Ref<T> } { const current = ref(initial) return { current } // type error: not assignable to Ref<T> } ``` The same code with a concrete type, `ref<User>(user)`, compiles without complaint. The error appears only in **generic** code, which is why it surprises people. ## Why `ref()` is not typed `Ref<T>` In Vue 3.5 the declaration for a non-ref argument is, in simplified form: ```ts function ref<T>(value: T): Ref<UnwrapRef<T>, UnwrapRef<T> | T> ``` Two parts matter: 1. **`UnwrapRef<T>` for reads.** A ref makes its value deeply reactive, and refs nested inside it are unwrapped: `ref({ count: ref(0) }).value.count` is a `number`. `UnwrapRef` is a **conditional type** that describes that unwrapping. 2. **`UnwrapRef<T> | T` for writes.** In Vue 3.5 `Ref` has separate read and write types, so assigning a raw `T` to `.value` is accepted. With a concrete type such as `User`, TypeScript evaluates `UnwrapRef<User>` straight to `User`. With a **type parameter**, the conditional cannot be evaluated until `T` is known, so it stays **deferred**, and TypeScript will not assume that a deferred `UnwrapRef<T>` equals `T`. Assigning the result to `Ref<T>` therefore fails. ## What still works Not everything breaks, which makes the problem easy to misread: - **Reading through the constraint.** Vue's own type tests check that inside `<T extends { name: string }>`, `ref(x).value.name` is a `string`. - **Writing a `T`.** `current.value = next` with `next: T` compiles in 3.5, because the write type includes `T`. - **Only the named type fails**: declaring or returning the ref as `Ref<T>`. ## The fixes | Fix | Resulting type | Trade-off | |---|---|---| | `ref(initial) as Ref<T>` | `Ref<T>` | A cast; honest only if `T` will not contain refs to unwrap | | `shallowRef(initial)` | `ShallowRef<T>`, assignable to `Ref<T>` | Only replacing `.value` is tracked, not deep mutations | | Return `Ref<UnwrapRef<T>>` | Exact | Leaks the helper type into every caller's signature | - **The cast** is what Vue's own type tests use for this case. It states what you mean: the composable stores a `T` and callers read a `T`. It becomes a lie only if a caller passes an object containing refs, which then read as unwrapped values. - **`shallowRef`** avoids the unwrap type entirely because a shallow ref does not unwrap or deeply convert its value. For a selection, a current page or a loaded record that is **replaced as a whole**, that is also the better runtime choice: no deep proxy is created. - **Exposing `UnwrapRef<T>`** is the most precise, and occasionally right for a library, but it makes every consumer's hover text harder to read. ```ts import { shallowRef, type Ref } from 'vue' export function useSelection<T>(initial: T): { current: Ref<T>; select(next: T): void } { const current = shallowRef(initial) return { current, select(next: T) { current.value = next } } } ``` ## Why this is a composable-author problem Application components almost never meet this error, because their refs hold concrete types. It is a **library and composable-author** concern: the moment a function is generic over the data it stores, the unwrap type shows up. That is also why interviewers use it as a senior probe: it separates candidates who have written reusable typed composables from those who have only consumed them. A good answer names the cause (a deferred conditional type describing deep unwrapping), shows that reads and writes still work, and chooses a fix by the runtime need, not only by what silences the checker. ## How to recognise it in review 1. The error mentions `UnwrapRef` or says `T` could be instantiated with a type unrelated to it. 2. It appears only in generic functions and disappears when the same code is written for a concrete type. 3. Someone has "fixed" it with `any`, which removes the error and every check with it. ## Pitfalls - Replacing `ref` with `shallowRef` for a value that **is mutated in place** (pushing into an array): the type is fixed but the UI stops updating. - Casting to `Ref<T>` in a composable whose callers do pass reactive objects with refs inside: the cast hides a real unwrap. - Annotating the whole function with `any` returns to silence the checker.

  • In Vue, why does `shallowRef(initial)` inside a generic function avoid the UnwrapRef problem?
    A shallow ref stores its value as-is: it neither makes it deeply reactive nor unwraps nested refs. Its declared type is therefore `ShallowRef<T>` with no unwrap helper, which is assignable to `Ref<T>`. The runtime cost is that only replacing `.value` triggers updates.
  • When is `ref(initial) as Ref<T>` in a Vue composable actually wrong?
    When a caller passes an object that contains refs. At runtime `ref` unwraps them, so a field declared as `Ref<number>` in `T` is read as a plain number, while the cast tells TypeScript it is still a ref. For plain data the cast is accurate.

saying these in an interview costs you the question

  • ref(initial) is typed Ref<T>, so the error must be a TypeScript bug.
  • Inside a generic composable you cannot read any property of ref(x).value.
  • shallowRef is a drop-in fix with no runtime difference from ref.
  • Typing the composable's return as any is an acceptable fix.
  • Assigning a raw T to .value of ref(initial) fails in Vue 3.5.