Vue 3.3+ supports generic components, enabling powerful patterns for reusable, type-safe components.
Basic Generic Component
vue<script setup lang="ts" generic="T"> defineProps<{ items: T[] selected?: T }>() const emit = defineEmits<{ select: [item: T] }>() </script> <template> <ul> <li v-for="item in items" :key="String(item)" @click="emit('select', item)" > <slot :item="item" /> </li> </ul> </template>
vue<!-- Usage --> <script setup lang="ts"> interface User { id: number name: string } const users: User[] = [...] const selectedUser = ref<User | undefined>() function handleSelect(user: User) { // user is typed as User! selectedUser.value = user } </script> <template> <GenericList :items="users" :selected="selectedUser" @select="handleSelect" > <template #default="{ item }"> <!-- item is typed as User --> {{ item.name }} </template> </GenericList> </template>
Multiple Type Parameters
vue<script setup lang="ts" generic="T, K extends keyof T"> defineProps<{ items: T[] labelKey: K valueKey: K }>() </script>
Generic with Constraints
vue<script setup lang="ts" generic="T extends { id: number }"> defineProps<{ items: T[] selectedId?: number }>() // T must have an id property const getById = (id: number) => props.items.find(item => item.id === id) </script>
Select Component Example
vue<!-- Select.vue --> <script setup lang="ts" generic="T"> import { computed } from 'vue' interface Props { options: T[] modelValue?: T labelKey?: keyof T placeholder?: string } const props = withDefaults(defineProps<Props>(), { placeholder: 'Select an option' }) const emit = defineEmits<{ 'update:modelValue': [value: T | undefined] }>() const getLabel = (option: T): string => { if (props.labelKey) { return String(option[props.labelKey]) } return String(option) } </script> <template> <select :value="modelValue" @change="emit('update:modelValue', options[($event.target as HTMLSelectElement).selectedIndex])" > <option disabled value="">{{ placeholder }}</option> <option v-for="(option, index) in options" :key="index" :value="option"> {{ getLabel(option) }} </option> </select> </template>
DataTable Generic Component
vue<script setup lang="ts" generic="T extends Record<string, unknown>"> interface Column<T> { key: keyof T label: string sortable?: boolean render?: (value: T[keyof T], row: T) => string } defineProps<{ data: T[] columns: Column<T>[] loading?: boolean }>() const emit = defineEmits<{ 'row-click': [row: T] sort: [key: keyof T, direction: 'asc' | 'desc'] }>() defineSlots<{ cell(props: { column: Column<T>; row: T; value: T[keyof T] }): any empty(): any loading(): any }>() </script> <template> <table> <thead> <tr> <th v-for="column in columns" :key="String(column.key)"> {{ column.label }} </th> </tr> </thead> <tbody> <tr v-if="loading"> <td :colspan="columns.length"> <slot name="loading">Loading...</slot> </td> </tr> <tr v-else-if="data.length === 0"> <td :colspan="columns.length"> <slot name="empty">No data</slot> </td> </tr> <tr v-else v-for="(row, index) in data" :key="index" @click="emit('row-click', row)" > <td v-for="column in columns" :key="String(column.key)"> <slot name="cell" :column="column" :row="row" :value="row[column.key]" > {{ column.render ? column.render(row[column.key], row) : row[column.key] }} </slot> </td> </tr> </tbody> </table> </template>
vue<!-- Usage --> <script setup lang="ts"> interface Product { id: number name: string price: number category: string } const products: Product[] = [...] const columns = [ { key: 'name' as const, label: 'Name', sortable: true }, { key: 'price' as const, label: 'Price', render: (v) => `$${v}` }, { key: 'category' as const, label: 'Category' } ] </script> <template> <DataTable :data="products" :columns="columns" @row-click="(product) => console.log(product.name)" /> </template>