skip to content

A generic Vue `<script setup generic="T">` List exposes `scrollTo(item: T)` with defineExpose; why does `InstanceType<typeof List>` fail for the parent's ref, and what works instead?

level: seniorimportance: nice to knowfreq 25%

answer

  1. closed by default, opened by a macro
  2. exposed refs arrive unwrapped
  3. a generic SFC is typed as a function
  4. InstanceType needs a construct signature
  5. a helper from vue-component-type-helpers

basics

~10 s

Vue's language tools type a generic SFC as a generic function, not a constructor, so InstanceType has nothing to extract. Use ComponentExposed<typeof List> from vue-component-type-helpers, or let language-tools 2.1+ infer a static template ref.

solid answer

~40 s

`<script setup>` components are closed by default; `defineExpose({ scrollTo })` publishes chosen bindings, and the parent sees them with refs shallowly unwrapped. For a non-generic SFC the tooling types the default export as a component constructor, so `InstanceType<typeof Comp>` works. A generic SFC is instead typed as a generic function `(props, ctx, expose) => ...`, because a constructor type cannot carry a per-usage type parameter, and `InstanceType` rejects anything without a construct signature. The docs' fix is `ComponentExposed<typeof List>` from `vue-component-type-helpers`, which pulls the type out of that `expose` parameter: `useTemplateRef<ComponentExposed<typeof List>>('list')`. Since `@vue/language-tools` 2.1, a static template ref is inferred automatically, so the explicit type matters in edge cases such as refs typed in a composable. Extracting from the generic signature does not know the parent's `T`; it falls back to `T`'s constraint.

go deeper

for a junior

Recall that script setup components are closed by default and that defineExpose publishes selected bindings to a parent's ref.

for a middle

Explain that exposed refs are unwrapped and that InstanceType gives a non-generic component's exposed type.

for a senior

Explain why a generic SFC is typed as a function, why InstanceType fails, how ComponentExposed extracts the expose parameter, and that T falls back to its constraint.

for a principal

Judge how much imperative API a shared generic component should expose, given every exposed member becomes a coupling point parents rely on.

## Exposing an API from `<script setup>` A component written with `<script setup>` is **closed by default**: a parent holding a template ref to it, or walking `$parent`, cannot see any binding declared in the block. The **`defineExpose`** compiler macro opens a deliberate public surface: ```vue <script setup lang="ts" generic="T extends { id: string | number }"> import { ref } from 'vue' const props = defineProps<{ items: T[] }>() const highlighted = ref<T | null>(null) function scrollTo(item: T) { highlighted.value = item // scroll logic } defineExpose({ scrollTo, highlighted }) </script> ``` Two typing facts follow directly from the macro: - The exposed type is the type of the **object literal** passed to `defineExpose`. - **Refs are shallowly unwrapped** on the public instance, so `highlighted` appears to the parent as `T | null`, not as a `Ref`. ## Why `InstanceType` stops working For an ordinary SFC, the Vue language tools type the default export as a component **constructor**, so the parent can write `ref<InstanceType<typeof Modal>>()` and get the exposed members. A **generic** SFC cannot be modelled that way, because a constructor type has no slot for a type parameter chosen per usage. The tools instead model it as a **generic function**: 1. Its type parameters are the ones in the `generic` attribute. 2. Its first parameter is the props, which is how `T` is inferred at each usage. 3. Its second parameter carries `attrs`, `emit` and `slots`. 4. Its third parameter is an `expose` callback whose argument is the `defineExpose` object with refs unwrapped. `InstanceType<X>` requires `X` to have a **construct signature** (`new (...) => ...`). A plain function type has none, so TypeScript reports a constraint error, and the docs state plainly that `InstanceType` won't work for generic components. ## What works instead | Situation | Typing that works | |---|---| | Non-generic child | `InstanceType<typeof Child>` | | Generic child, explicit annotation | `ComponentExposed<typeof List>` from `vue-component-type-helpers` | | Static `ref="list"` in the template, language-tools 2.1+ | Inferred automatically from the template | | Loose typing is acceptable | `ComponentPublicInstance` (no exposed members typed) | `ComponentExposed` is a small conditional type: for a constructor it returns the instance type; for a function it **infers the argument of the third (`expose`) parameter**. In use: ```vue <script setup lang="ts"> import { useTemplateRef } from 'vue' import type { ComponentExposed } from 'vue-component-type-helpers' import List from './List.vue' const list = useTemplateRef<ComponentExposed<typeof List>>('list') function jumpToFirst(user: User) { list.value?.scrollTo(user) } </script> ``` ## The `T` you get back Because `ComponentExposed` reads the type off the **generic signature**, not off a particular `<List :items="users">` usage, TypeScript has no usage to infer from. It falls back to the type parameter's **constraint**, here `{ id: string | number }` (or `unknown` for an unconstrained `T`). So `scrollTo` accepts any object with an `id`, not specifically a `User`. That is usually acceptable for calling an imperative method; if it is not, wrap the call in a function typed with the concrete item type. ## A worked failure A typical interview version of this problem runs as follows: 1. A team converts its `DataList.vue` to `generic="T"` so parents get typed rows. 2. A parent that previously declared `const list = ref<InstanceType<typeof DataList>>()` now fails type checking with a constraint error on `InstanceType`. 3. A quick fix of `ref<any>()` silences it and also silences every check on `list.value.scrollTo(...)`. 4. The correct fix is either to delete the annotation and let the language tools infer the static template ref, or to switch the annotation to `ComponentExposed<typeof DataList>`. The lesson: the generic conversion changed the component's **type shape** from constructor to function, and every parent that named that shape must follow. ## Judgment calls - **Keep the exposed surface small.** Every exposed member is public API that parents can couple to. - **Prefer props and events** for data flow; exposure is for imperative actions such as focus, scroll or reset. - **Rely on inference first.** With current language tools, a static template ref usually needs no annotation; reach for `ComponentExposed` when a ref is declared in a composable, passed around, or bound dynamically. - **Check it in CI.** None of this exists at runtime; only `vue-tsc` catches a parent calling a method the child stopped exposing.

  • In Vue, what type does a parent see for a child's `defineExpose({ count: ref(0) })` member?
    `number`. The public instance shallowly unwraps refs, just like on a normal component instance, so the parent reads `child.value?.count` as a number and never touches `.value` on it. The Vue language tools model this with `ShallowUnwrapRef` over the object passed to `defineExpose`.
  • What does the parent's ref hold for a Vue `<script setup>` child that never calls defineExpose?
    The component's public instance with none of the `<script setup>` bindings on it, because such components are closed by default. Built-in public properties are still there, but local refs and functions are not reachable until the child exposes them explicitly.

saying these in an interview costs you the question

  • InstanceType<typeof List> works for every single-file component, generic or not.
  • You fix it by writing InstanceType<typeof List<User>> with the type argument.
  • Exposed refs arrive in the parent as Ref objects, so you must read .value.
  • Script setup components expose all their top-level bindings unless told otherwise.
  • ComponentExposed binds T to the parent's actual item type automatically.