site, preserving the exact shape passed by the parent component. Below is a production-ready implementation pattern using a SortableList component.
Step 1: Define the Generic Constraint
Add the generic attribute to the <script setup> tag. The constraint should specify the minimum required shape, not the full object. This keeps the component flexible while guaranteeing essential fields exist.
<script setup lang="ts" generic="RecordType extends { id: string | number }">
import { ref, computed } from 'vue'
const props = defineProps<{
items: RecordType[]
sortKey: keyof RecordType
}>()
const emit = defineEmits<{
selection: [payload: RecordType]
}>()
</script>
Architecture Rationale:
lang="ts" is mandatory. The Vue compiler ignores generic attributes without it.
- The constraint
extends { id: string | number } ensures every item has a stable identifier for keying and selection logic. It does not restrict additional fields.
sortKey: keyof RecordType leverages TypeScript's indexed access types to guarantee the sort field actually exists on the passed data.
Step 2: Implement Reactive Logic with Type Preservation
Use computed properties and refs that maintain the generic type throughout the component lifecycle.
<script setup lang="ts" generic="RecordType extends { id: string | number }">
// ... previous imports and props
const selectedIndex = ref<string | number | null>(null)
const sortedItems = computed(() => {
return [...props.items].sort((a, b) => {
const valA = a[props.sortKey]
const valB = b[props.sortKey]
return valA < valB ? -1 : valA > valB ? 1 : 0
})
})
function handleSelect(item: RecordType) {
selectedIndex.value = item.id
emit('selection', item)
}
</script>
Why this works: TypeScript tracks RecordType through the computed property and event handler. The sortedItems array retains the exact shape of props.items, and handleSelect guarantees the emitted payload matches the consumer's original type.
Step 3: Expose Typed Scoped Slots
Scoped slots automatically inherit the generic type when referenced in the template. No additional type declarations are needed.
<template>
<ul class="list-container">
<li
v-for="(item, index) in sortedItems"
:key="item.id"
:class="{ 'is-selected': item.id === selectedIndex }"
@click="handleSelect(item)"
>
<slot
:record="item"
:index="index"
:is-selected="item.id === selectedIndex"
/>
</li>
</ul>
</template>
Step 4: Consume the Component
The parent component passes data, and TypeScript infers the generic type automatically. Explicit type parameters are optional but recommended for complex interfaces.
<script setup lang="ts">
import SortableList from './SortableList.vue'
interface ProductData {
id: number
name: string
sku: string
inventory: number
}
const products: ProductData[] = [
{ id: 101, name: 'Wireless Mouse', sku: 'WM-001', inventory: 42 },
{ id: 102, name: 'Mechanical Keyboard', sku: 'MK-002', inventory: 18 }
]
function onProductSelect(product: ProductData) {
console.log(`Selected: ${product.name} (${product.sku})`)
}
</script>
<template>
<SortableList
:items="products"
sort-key="name"
@selection="onProductSelect"
>
<template #default="{ record, index, isSelected }">
<span class="item-index">#{{ index + 1 }}</span>
<strong>{{ record.name }}</strong>
<span class="stock-badge" :class="{ 'low-stock': record.inventory < 20 }">
{{ record.inventory }} units
</span>
<span v-if="isSelected" class="selection-indicator">✓</span>
</template>
</SortableList>
</template>
Key Insight: The slot template receives record typed as ProductData, not the base constraint. Fields like sku and inventory are fully available with autocomplete and compile-time validation. The generic constraint acts as a contract floor, not a ceiling.
Pitfall Guide
1. Over-Constraining the Generic Type
Explanation: Defining constraints that include non-essential fields (e.g., T extends { id: string; name: string; email: string }) forces consumers to match the exact shape, defeating the purpose of generics.
Fix: Constrain only to fields the component actually uses. If the component only needs an identifier, use T extends { id: string | number }.
2. Omitting lang="ts" on <script setup>
Explanation: The Vue compiler silently ignores the generic attribute if TypeScript is not explicitly enabled. The component falls back to any inference, causing type erosion.
Fix: Always declare <script setup lang="ts" generic="...">. Verify with your IDE that no type warnings appear on the script tag.
3. Mixing Runtime Validation with Generic Constraints
Explanation: Generics are compile-time only. They do not validate data at runtime. Relying on them for input sanitization or API response validation leads to runtime crashes.
Fix: Use generics for type contracts. Pair them with runtime validators like zod or valibot when handling external data. Example: const validated = schema.parse(props.items).
4. Ignoring Emit Type Propagation
Explanation: Developers often type props with generics but forget to apply the same parameter to defineEmits. This causes parent event handlers to receive any or mismatched payloads.
Fix: Always mirror the generic in emit definitions: defineEmits<{ change: [payload: T] }>().
5. Assuming Generic Inference Works with Dynamic Arrays
Explanation: Passing arrays created with [] or Array() without explicit typing causes TypeScript to infer never[] or unknown[], breaking generic resolution.
Fix: Explicitly type array declarations or use as const for literal arrays: const data: MyType[] = [...] or const data = [...] as const.
6. Circular Generic Constraints
Explanation: Defining constraints that reference themselves or create circular dependencies (e.g., T extends { children: T[] }) can cause compiler hangs or infinite type resolution loops.
Fix: Flatten recursive structures using interfaces or utility types. Use T extends { children?: T[] } with optional chaining to break strict circularity.
7. Default Generic Values Causing Silent Fallbacks
Explanation: Providing default generic types (e.g., generic="T = BaseInterface") can mask type mismatches when consumers forget to pass explicit types, leading to unexpected narrowing.
Fix: Avoid defaults unless absolutely necessary. Prefer explicit type parameters at the call site or rely on inference from strongly-typed props.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Internal dashboard with fixed data shapes | Hardcoded interfaces | Simpler setup, no generic overhead | Low |
| Reusable UI library or design system | Generic constraints (<script setup generic="...">) | Maximizes reusability, preserves type fidelity across consumers | Medium (initial setup) |
| Rapid prototype or throwaway feature | any or unknown with runtime guards | Speed prioritized over type safety | Low |
| Complex form builder with dynamic fields | Generics + Record<string, unknown> + runtime schema | Balances flexibility with validation safety | High |
| Third-party component integration | Wrapper component with explicit type mapping | Isolates external type instability from internal codebase | Medium |
Configuration Template
<script setup lang="ts" generic="ItemType extends { id: string | number }">
import { computed, ref } from 'vue'
/**
* Generic constraint ensures every item has a stable identifier.
* Additional fields are preserved through props, emits, and slots.
*/
const props = defineProps<{
collection: ItemType[]
identifierKey?: keyof ItemType
}>()
const emit = defineEmits<{
activate: [item: ItemType]
deactivate: [item: ItemType]
}>()
const activeId = ref<ItemType['id'] | null>(null)
const normalizedCollection = computed(() => {
return props.collection.map(item => ({
...item,
_internalKey: item[props.identifierKey ?? 'id']
}))
})
function toggleItem(item: ItemType) {
if (activeId.value === item.id) {
activeId.value = null
emit('deactivate', item)
} else {
activeId.value = item.id
emit('activate', item)
}
}
</script>
<template>
<div class="generic-container">
<div
v-for="item in normalizedCollection"
:key="item.id"
class="item-row"
:class="{ 'is-active': item.id === activeId }"
@click="toggleItem(item)"
>
<slot
:data="item"
:is-active="item.id === activeId"
/>
</div>
</div>
</template>
Quick Start Guide
- Create the component file: Run
touch GenericComponent.vue and open it in your editor.
- Add the script block: Insert
<script setup lang="ts" generic="T extends { id: string | number }"> and define your props/emits using T.
- Implement template logic: Use
v-for with the generic-typed prop and expose a scoped slot that passes the item data.
- Consume in parent: Import the component, pass a strongly-typed array, and verify that the slot template provides full autocomplete for custom fields.
- Validate compilation: Run
vue-tsc --noEmit or check your IDE for type errors. If inference fails, explicitly pass the type parameter: <GenericComponent<T: MyInterface> ... />.