---
title: "Generic components with <script setup generic> · vuejs.school"
description: "A list component that keeps the type of its items from prop to slot to event: the generic attribute, typed v-model, constraints, and where it stops."
canonical_url: "https://vuejs.school/community/blog/generic-components-with-script-setup-generic"
meta:
  "og:description": "A list component that keeps the type of its items from prop to slot to event: the generic attribute, typed v-model, constraints, and where it stops."
  "og:image": "https://vuejs.school/_og/d/a_~R2VuZXJpYyBjb21wb25lbnRzIHdpdGggPHNjcmlwdCBzZXR1cCBnZW5lcmljPiDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~R2VuZXJpYyBjb21wb25lbnRzIHdpdGggPHNjcmlwdCBzZXR1cCBnZW5lcmljPg%2Cdescription_~QSBsaXN0IGNvbXBvbmVudCB0aGF0IGtlZXBzIHRoZSB0eXBlIG9mIGl0cyBpdGVtcyBmcm9tIHByb3AgdG8gc2xvdCB0byBldmVudDogdGhlIGdlbmVyaWMgYXR0cmlidXRlLCB0eXBlZCB2LW1vZGVsLCBjb25zdHJhaW50cywgYW5kIHdoZXJlIGl0IHN0b3BzLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNSBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy9nZW5lcmljLWNvbXBvbmVudHMtd2l0aC1zY3JpcHQtc2V0dXAtZ2VuZXJpYyI%2Cs_Nsb7pvAyUNS7L3Iy.png"
  "og:image:alt": "Generic components with <script setup generic> · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "Generic components with <script setup generic> · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~R2VuZXJpYyBjb21wb25lbnRzIHdpdGggPHNjcmlwdCBzZXR1cCBnZW5lcmljPiDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~R2VuZXJpYyBjb21wb25lbnRzIHdpdGggPHNjcmlwdCBzZXR1cCBnZW5lcmljPg%2Cdescription_~QSBsaXN0IGNvbXBvbmVudCB0aGF0IGtlZXBzIHRoZSB0eXBlIG9mIGl0cyBpdGVtcyBmcm9tIHByb3AgdG8gc2xvdCB0byBldmVudDogdGhlIGdlbmVyaWMgYXR0cmlidXRlLCB0eXBlZCB2LW1vZGVsLCBjb25zdHJhaW50cywgYW5kIHdoZXJlIGl0IHN0b3BzLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNSBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy9nZW5lcmljLWNvbXBvbmVudHMtd2l0aC1zY3JpcHQtc2V0dXAtZ2VuZXJpYyI%2Cs_Nsb7pvAyUNS7L3Iy.png"
  "twitter:image:alt": "Generic components with <script setup generic> · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

## 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`:

*src/components/SelectList.vue**Breaks***

```
<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:

*src/App.vue**Breaks***

```
<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:

*src/components/SelectList.vue**Works***

```
<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:

*src/components/SelectList.vue*

```
<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 typed `item`.
- The `item` slot is optional (the `?`) because the component has fallback content. Its props object names both values the parent can destructure, `item` and `selected`.
- `label` is 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:

*src/App.vue**Works***

```
<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:

*src/components/SelectList.vue*

```
<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>>()
```

**Check it stuck**3 questions from the Generic components, slots and expose quiz.

[**Practice now**](https://vuejs.school/learn/typescript/generic-components-and-slots?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Tutorial · 6 min read**TypeScript strict mode in a Vue project: the errors you'll hit and the fixes**](https://vuejs.school/community/blog/typescript-strict-mode-in-a-vue-project) [Release notes · 7 min read**TypeScript 7.0: the Go compiler lands, and what it means for vue-tsc**](https://vuejs.school/community/blog/typescript-7-the-go-compiler-lands)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/generic-components-with-script-setup-generic#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-08-12","description":"A list component that keeps the type of its items from prop to slot to event: the generic attribute, typed v-model, constraints, and where it stops.","name":"Generic components with \u003Cscript setup generic>","url":"https://vuejs.school/community/blog/generic-components-with-script-setup-generic","breadcrumb":{"@id":"https://vuejs.school/community/blog/generic-components-with-script-setup-generic#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/generic-components-with-script-setup-generic"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/generic-components-with-script-setup-generic#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-08-12","description":"A list component that keeps the type of its items from prop to slot to event: the generic attribute, typed v-model, constraints, and where it stops.","headline":"Generic components with \u003Cscript setup generic>","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~R2VuZXJpYyBjb21wb25lbnRzIHdpdGggPHNjcmlwdCBzZXR1cCBnZW5lcmljPiDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~R2VuZXJpYyBjb21wb25lbnRzIHdpdGggPHNjcmlwdCBzZXR1cCBnZW5lcmljPg%2Cdescription_~QSBsaXN0IGNvbXBvbmVudCB0aGF0IGtlZXBzIHRoZSB0eXBlIG9mIGl0cyBpdGVtcyBmcm9tIHByb3AgdG8gc2xvdCB0byBldmVudDogdGhlIGdlbmVyaWMgYXR0cmlidXRlLCB0eXBlZCB2LW1vZGVsLCBjb25zdHJhaW50cywgYW5kIHdoZXJlIGl0IHN0b3BzLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNSBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy9nZW5lcmljLWNvbXBvbmVudHMtd2l0aC1zY3JpcHQtc2V0dXAtZ2VuZXJpYyI%2Cs_Nsb7pvAyUNS7L3Iy.png","@type":["Article","BlogPosting"],"articleSection":["Tutorial"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/generic-components-with-script-setup-generic#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/generic-components-with-script-setup-generic#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/generic-components-with-script-setup-generic#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"Generic components with \u003Cscript setup generic>","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~R2VuZXJpYyBjb21wb25lbnRzIHdpdGggPHNjcmlwdCBzZXR1cCBnZW5lcmljPiDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~R2VuZXJpYyBjb21wb25lbnRzIHdpdGggPHNjcmlwdCBzZXR1cCBnZW5lcmljPg%2Cdescription_~QSBsaXN0IGNvbXBvbmVudCB0aGF0IGtlZXBzIHRoZSB0eXBlIG9mIGl0cyBpdGVtcyBmcm9tIHByb3AgdG8gc2xvdCB0byBldmVudDogdGhlIGdlbmVyaWMgYXR0cmlidXRlLCB0eXBlZCB2LW1vZGVsLCBjb25zdHJhaW50cywgYW5kIHdoZXJlIGl0IHN0b3BzLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNSBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy9nZW5lcmljLWNvbXBvbmVudHMtd2l0aC1zY3JpcHQtc2V0dXAtZ2VuZXJpYyI%2Cs_Nsb7pvAyUNS7L3Iy.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~R2VuZXJpYyBjb21wb25lbnRzIHdpdGggPHNjcmlwdCBzZXR1cCBnZW5lcmljPiDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~R2VuZXJpYyBjb21wb25lbnRzIHdpdGggPHNjcmlwdCBzZXR1cCBnZW5lcmljPg%2Cdescription_~QSBsaXN0IGNvbXBvbmVudCB0aGF0IGtlZXBzIHRoZSB0eXBlIG9mIGl0cyBpdGVtcyBmcm9tIHByb3AgdG8gc2xvdCB0byBldmVudDogdGhlIGdlbmVyaWMgYXR0cmlidXRlLCB0eXBlZCB2LW1vZGVsLCBjb25zdHJhaW50cywgYW5kIHdoZXJlIGl0IHN0b3BzLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNSBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy9nZW5lcmljLWNvbXBvbmVudHMtd2l0aC1zY3JpcHQtc2V0dXAtZ2VuZXJpYyI%2Cs_Nsb7pvAyUNS7L3Iy.png"}]}
```