A component that takes a list and lets the user pick one is the kind of thing you write once and reuse everywhere. The catch is the type of the items. If the component says items: any[], every parent loses autocomplete in the slot and the select event hands back any. If it says items: User[], you cannot reuse it for products. The generic attribute on <script setup> solves this. It gives the component a type parameter, T, the way function first<T>(list: T[]): T gives one to a function, and the items each parent passes decide what T is.
You will build a SelectList that takes its item type from the :items you pass, types its v-model, its select event and its item slot to match, and refuses a label-key the item does not have. The examples use a plain Vite project from create-vue with Vue 3.5, vue-tsc 3.0 and the Vue (Official) extension in VS Code. TypeScript 5.9 was published on 31 July; nothing here needs it, but everything here works on it.
Why a plain list component loses the item type
Here is the component without generics. It accepts anything with an id, because it needs one for the key:
<script setup lang="ts">
type Item = { id: string }
defineProps<{ items: Item[] }>()
const selected = defineModel<Item | null>({ default: null })
defineSlots<{
item?: (props: { item: Item }) => any
}>()
</script>
<template>
<ul>
<li v-for="item in items" :key="item.id">
<button type="button" @click="selected = item">
<slot name="item" :item="item">{{ item.id }}</slot>
</button>
</li>
</ul>
</template>
In the parent, users holds User objects with an id, a name and an email (the full parent comes later). Pass them in and try to show the email in the slot:
<SelectList v-model="current" :items="users">
<template #item="{ item }">
{{ item.name }} ({{ item.email }})
</template>
</SelectList>
vue-tsc reports Property 'name' does not exist on type 'Item'. The component told the truth about what it needs, and in doing so it threw away what the parent knows. Widening Item to any makes the error go away and takes the checking with it. Neither is good.
Declare the type parameter with generic
Add generic to the script tag. Its value is written exactly like the part between < and > on a generic function, so constraints with extends work:
<script setup lang="ts" generic="T extends { id: string }">
defineProps<{ items: T[] }>()
const selected = defineModel<T | null>({ default: null })
</script>
Read it as "this component works for any T that at least has a string id". Inside the script and the template, T is a real type. items is T[], the item in v-for is T, and the model is T | null. The constraint is what lets you write item.id in the key binding: TypeScript knows every T has one.
The constraint is also the contract with the parent. A parent that passes [{ name: 'Ada' }] gets an error at the call site, because the object in it has no id. That is the error you want: at the place that broke the rule, not deep inside the component.
Type the event and the slot with T
defineEmits and defineSlots take type arguments, and those can mention T. Here is the full component:
<script setup lang="ts" generic="T extends { id: string }">
const props = defineProps<{
items: T[]
label: (item: T) => string
}>()
const selected = defineModel<T | null>({ default: null })
const emit = defineEmits<{
select: [item: T]
}>()
defineSlots<{
item?: (props: { item: T; selected: boolean }) => any
}>()
function choose(item: T) {
selected.value = item
emit('select', item)
}
</script>
<template>
<ul>
<li v-for="item in props.items" :key="item.id">
<button
type="button"
:aria-pressed="selected?.id === item.id"
@click="choose(item)"
>
<slot name="item" :item="item" :selected="selected?.id === item.id">
{{ props.label(item) }}
</slot>
</button>
</li>
</ul>
</template>
Three things to notice:
select: [item: T]declares the event with the tuple syntax, so a listener receives one typeditem.- The
itemslot is optional (the?) because the component has fallback content. Its props object names both values the parent can destructure,itemandselected. labelis a prop that holds a function: a plain prop, nothing special. It keeps the component out of the business of knowing which field is the display text.
defineSlots takes only a type argument and no runtime value. The return type of each slot function is ignored, which is why any is fine there.
Use it from a parent and let TypeScript infer T
Now the parent passes users and gets users back everywhere:
<script setup lang="ts">
import { ref } from 'vue'
import SelectList from '@/components/SelectList.vue'
type User = { id: string; name: string; email: string }
const users = ref<User[]>([
{ id: 'u1', name: 'Ada', email: 'ada@example.com' },
{ id: 'u2', name: 'Grace', email: 'grace@example.com' },
])
const current = ref<User | null>(null)
function onSelect(user: User) {
console.log('picked', user.email)
}
</script>
<template>
<SelectList
v-model="current"
:items="users"
:label="user => user.name"
@select="onSelect"
>
<template #item="{ item, selected }">
<strong v-if="selected">{{ item.name }}</strong>
<span v-else>{{ item.name }} ({{ item.email }})</span>
</template>
</SelectList>
</template>
In the slot, item has every User field, so item.email autocompletes and type-checks. Change onSelect to take a Product and the @select line turns red. Pass :items="products" while current is still a ref<User | null> and v-model turns red, because one T has to satisfy both.
Nowhere did you tell the component that T is User. vue-tsc compiles the component into something close to a generic function, and every use in a template is a call to it. TypeScript looks at the arguments, here the props, and picks the T that fits all of them. :items="users" says T is User. Nothing else is needed. You never write <SelectList<User>>; there is no syntax for that in a template.
Name the type when there is nothing to infer from
Inference has one blind spot. When the only evidence is an empty array literal, TypeScript infers never[], so T becomes never: the slot's item and the argument of label are both never, and user.name is an error. The language tools provide a comment directive for that case:
<template>
<!-- @vue-generic {User} -->
<SelectList :items="[]" :label="user => user.name" />
</template>
Put it on the line above the component. You will rarely need it. An items binding to a typed ref is almost always enough, and a typed v-model counts as evidence too.
Add a second type parameter for a field name
To see one type parameter depend on another, here is a cut-down SelectList that takes a field name, labelKey, instead of the label function. Separate the parameters with a comma; the second one can refer to the first:
<script setup lang="ts" generic="T extends { id: string }, K extends keyof T">
defineProps<{
items: T[]
labelKey: K
}>()
</script>
<template>
<ul>
<li v-for="item in items" :key="item.id">{{ item[labelKey] }}</li>
</ul>
</template>
K extends keyof T means "a key that exists on whatever T turned out to be". At the call site, <SelectList :items="users" label-key="name" /> infers T as User and K as "name". Write label-key="nmae" and you get Type '"nmae"' is not assignable to type 'keyof User' before the page ever loads.
The template can read item[labelKey] without a cast, and it renders whatever that field holds. If the field must be text, say so on the item type in the parent; a key constraint alone cannot say anything about the value behind the key.
Default type arguments work as well, generic="T extends { id: string } = { id: string }", though inference makes them rare in practice.
What generic components cannot do
Generics in <script setup> are a compile-time feature, and that sets their limits.
There is nothing at runtime. T is erased, as with every TypeScript type. The component cannot branch on it, validate it, or read it in defineProps as a runtime option. Vue's compiler still turns the prop types into runtime prop options, so items: T[] becomes { type: Array } and label becomes { type: Function }, and that is all the browser ever sees.
The attribute needs lang="ts", and the type checking happens in the Vue language tools: the Vue (Official) extension (version 3) in VS Code, and vue-tsc --build or vue-tsc --noEmit on your project in CI. The Vite build ignores generic and does no type checking, so without those tools a wrong slot prop or event type goes unnoticed.
InstanceType does not work on a generic component, because its type is a generic function rather than a class. If you need a template ref to one, for an exposed method for example, use ComponentExposed from the small vue-component-type-helpers package in the parent's <script setup lang="ts">:
import { ref } from 'vue'
import type { ComponentExposed } from 'vue-component-type-helpers'
import SelectList from '@/components/SelectList.vue'
const list = ref<ComponentExposed<typeof SelectList>>()