Generic Components
Declare a type parameter with generic on the script block, let props infer it, and pin it with @vue-generic when they cannot.
<script setup lang="ts" generic="T extends { id: number }">A generic component is one whose prop types relate to each other instead of being fixed: a list that takes items: T[], reports the selected T, and exposes a T to its slot, whatever T turns out to be. Without it you either write one component per item type or fall back to any and lose the checking.
Declaring the parameter
Add the generic attribute to the script tag: <script setup lang="ts" generic="T">. Its value is exactly what you would write between angle brackets in TypeScript, so several parameters, extends constraints, default types and references to imported types all work. It requires lang="ts".
How inference works
T is resolved per usage from the props the parent passes. Bind :items="tracks" where tracks is Track[] and, for that one tag, the slot prop, the emitted payload and every other T in the component are Track. Another tag in another file can be another type.
When inference fails
If no prop pins T, it falls back to its constraint, or to unknown. The error you see is downstream and confusing: a property access on the slot prop fails with Property 'title' does not exist on type 'unknown'. Pin the type in the template with a @vue-generic comment above the tag.
Refs
InstanceType does not work on a generic component. Use ComponentExposed from vue-component-type-helpers.
Example
<script setup lang="ts" generic="T extends { id: number }">
defineProps<{
items: T[]
selected: T | null
}>()
const emit = defineEmits<{
select: [item: T]
}>()
</script>
<template>
<ul>
<li v-for="item in items" :key="item.id">
<button
type="button"
:aria-current="item === selected"
@click="emit('select', item)"
>
<slot :item="item">{{ item.id }}</slot>
</button>
</li>
</ul>
</template>
<style scoped>
ul {
padding-left: 0;
list-style: none;
}
button[aria-current='true'] {
font-weight: 700;
}
</style>When to use it
- One list, table or combo box component used with several unrelated row types.
- Keeping a scoped slot's item fully typed instead of widening it to a shared base interface.
- A picker whose item type only appears in its internals, pinned per usage with @vue-generic.
- Replacing three near-identical components that differ only in the shape of their data.
More examples
The parent, where T becomes Track
Nothing here names the type parameter: `:items="tracks"` pins T to Track, so `item` in the slot is a Track and `item.title` is checked. Pass `:selected` a value of another shape and the error lands on that prop. This is the payoff of a generic component over one typed with a shared base interface, where the slot prop would only have the base's properties.
<script setup lang="ts">
import { ref } from 'vue'
import SelectList from './SelectList.vue'
interface Track {
id: number
title: string
}
const tracks = ref<Track[]>([
{ id: 1, title: 'Intro' },
{ id: 2, title: 'Verse' }
])
const current = ref<Track | null>(null)
function choose(track: Track): void {
current.value = track
}
</script>
<template>
<select-list :items="tracks" :selected="current" @select="choose">
<!-- #default is shorthand for v-slot:default -->
<template #default="{ item }">{{ item.title }}</template>
</select-list>
<p v-if="current">Playing {{ current.title }}</p>
</template>
<style scoped>
p {
font-weight: 600;
}
</style>Pinning the type with @vue-generic
IdPicker is generic in the row type it works with, but no prop here carries that type: the model is a number either way. Inference has nothing to work from, so the type is supplied explicitly in an HTML comment directly above the tag, written as a TypeScript type inside braces. An inline `import(...)` keeps the script block free of an import that would otherwise be unused.
<script setup lang="ts">
import { ref } from 'vue'
import IdPicker from './IdPicker.vue'
const trackId = ref<number | null>(null)
const albumId = ref<number | null>(null)
</script>
<template>
<!-- @vue-generic {import('./types').Track} -->
<id-picker v-model="trackId" label="Track" />
<!-- @vue-generic {import('./types').Album} -->
<id-picker v-model="albumId" label="Album" />
</template>
<style scoped>
div {
margin-bottom: 0.5rem;
}
</style>A template ref to a generic component
This assumes SelectList calls defineExpose({ focusFirst }) to publish a method. Reaching that method through a ref needs the type of what the component exposes, and the usual InstanceType utility cannot produce it for a generic component. ComponentExposed, from the vue-component-type-helpers package, can. With Vue 3.5 and current tooling a static ref to a non-generic component is inferred with no annotation at all, so this is the one case where you still write the type by hand.
<script setup lang="ts">
import { ref, useTemplateRef } from 'vue'
import type { ComponentExposed } from 'vue-component-type-helpers'
import SelectList from './SelectList.vue'
import type { Track } from './types'
const tracks = ref<Track[]>([])
const current = ref<Track | null>(null)
// InstanceType<typeof SelectList> would not work here.
const list = useTemplateRef<ComponentExposed<typeof SelectList>>('list')
function focusFirst(): void {
list.value?.focusFirst()
}
</script>
<template>
<button type="button" @click="focusFirst">Focus the list</button>
<select-list ref="list" :items="tracks" :selected="current" />
</template>
<style scoped>
button {
margin-bottom: 0.5rem;
}
</style>
Discussion