Generic components, slots and expose
Write one list component for any item type with generic="T", type its scoped slots with defineSlots(), and expose only what parents need.
What you'll learn
When you finish, you can do these.- 1Write a generic list component for any item type
- 2Type what a slot passes to the parent with defineSlots()
- 3Expose a function with defineExpose() for a parent to call
- 4Fix a missing constraint and an empty list with no type
Picture a shop that needs a pick-one list in three places: fruit on the product page, tasks in the back office, users in the admin panel. Written once, as one component, the list must say what type its items have. Choose a fruit type, and tasks no longer fit. Choose any, the type that turns checking off, and a typo such as item.nmae slips through.
The Code tab holds the answer, a generic component in SelectList.vue: each page that uses it fills in its own item type. App.vue, the page beside it, lists three fruits with it, decides how each row looks, and empties the choice with a Clear button. You need one idea from the lesson Typing refs, computed and inject: in ref<string[]>([]), the type between the angle brackets says what goes in, here a list of strings.
One component, a type per use
A generic component takes a type parameter, usually named T: a stand-in for the item type that each use fills in. ref() has one too: string[] fills it in ref<string[]>([]). On a component, the generic attribute of the script tag declares it:
<script setup lang="ts" generic="T extends { id: string | number }">
const { items } = defineProps<{ items: T[] }>()
const selected = defineModel<T>()
</script>
Every T inside means the same item type, whatever it turns out to be. The items prop is a list of T, and selected, the item that v-model holds, is one T.
TypeScript infers T at each use: it works the type out from the props. App passes :items="fruits", a Fruit[] in which each fruit has an id, a name and a weight in kg. So in that list T is Fruit, and the picked item is a Fruit too. A second list given tasks, a Task[], gets T = Task, with no change to SelectList.
extends { id: string | number } is a constraint: whatever T is, it has an id, a string or a number. That is what lets SelectList's template read item.id on every item. The constraint is only a minimum: T is still the whole Fruit, name and kg included.
Typed slots
A slot is a place in SelectList that App fills with its own markup, so App decides how each row looks. defineSlots() types what the slot hands to App. Inside the loop that draws each row, SelectList marks that place with a slot outlet, a <slot> tag named item:
<slot name="item" :item="item" :active="selected?.id === item.id">{{ item.id }}</slot>
:item and :active are slot props, the values the outlet passes up to App: the item, and whether it is the picked one. A slot that passes values up is a scoped slot.
App fills the outlet with <template #item>, # plus the slot's name, and picks up both slot props by name:
<SelectList ref="list" v-model="picked" :items="fruits">
<template #item="{ item, active }">
{{ active ? '✓' : '' }} {{ item.name }} ({{ item.kg }} kg)
</template>
</SelectList>
The preview shows three rows: Mango (0.4 kg), Melon (2.1 kg) and Fig (0.05 kg). active ? '✓' : '' puts a check mark before the row you picked, and nothing before the others.
In SelectList's script, defineSlots() gives each slot's props a type:
defineSlots<{
item(props: { item: T; active: boolean }): any
empty?(): any
}>()
Each entry is a slot name with the props it passes: the item slot passes item, a T, and active, a boolean (true or false). So in App, item is a Fruit without App writing a type: the editor suggests item.name and item.kg, and a wrong name such as item.weight fails in vue-tsc, the command that type-checks a Vue project: Property 'weight' does not exist on type 'Fruit'.
empty passes nothing, and its ? means App may leave it out. SelectList shows it when items is empty. A <slot> tag's own content shows only when App fills nothing in: the item's id, or Nothing to show.
Expose and the parent's ref
App's Clear button calls clear(), a function inside SelectList. A parent may call only what defineExpose() lists; everything else in <script setup> stays private:
function clear() {
selected.value = undefined
}
defineExpose({ clear })
clear() sets the picked item back to undefined. Pick Melon, then click Clear: the check mark goes, and the line under the list reads Picked: nothing.
App reaches SelectList through a template ref, a ref that holds the component marked ref="list": const list = useTemplateRef('list'). Its type comes from that attribute and from what SelectList exposes, so vue-tsc checks the Clear button's list?.clear().
The ?. in list?.clear() skips the call only while list.value is null, before SelectList is on the page. It does not check that the function exists: SelectList exposes no reset(), so list?.reset() calls undefined, and in a project the click throws TypeError: list.value?.reset is not a function.
What goes wrong
Both mistakes leave the page running, so the playground shows nothing wrong: only vue-tsc and your editor catch them. The first leaves out the constraint.
<script setup lang="ts" generic="T">
Now T could be a number or a string, which have no id. vue-tsc flags every read of item.id: Property 'id' does not exist on type 'T'.
<script setup lang="ts" generic="T extends { id: string | number }">
The second mistake passes an empty array, written straight into the template, to a SelectList meant for tasks.
<SelectList :items="[]">
<template #item="{ item }">{{ item.title }}</template>
</SelectList>
[] gives TypeScript nothing to infer T from, so T becomes never, the type with no values, and every slot prop is unusable: Property 'title' does not exist on type 'never'. Give the list a type, even while it is empty.
<script setup lang="ts">
interface Task { id: string; title: string }
const tasks = ref<Task[]>([])
</script>
<template>
<SelectList :items="tasks">
<template #item="{ item }">{{ item.title }}</template>
</SelectList>
</template>
What to remember
generic="T extends { … }"gives a component a type parameter, and TypeScript infersTat each use from the props, so one list servesFruit[]andTask[]alike.- The constraint is what lets the component read
item.id. Without it,Tcould be anything and vue-tsc rejects every property read. defineSlots()types each slot's props, so the parent's#item="{ item }"gets the item type and typos are caught.defineExpose()is the parent's whole view of the component. Rely onuseTemplateRef()inference, or useComponentExposed;InstanceTypedoes not work for generic components.- An empty array literal infers
never. Pass a typed ref such asref<Task[]>([]).
Try it
Open the Code tab and change the starter code. Each task says what you should see.- In App.vue, replace the Try it 1 comment with
interface Task { id: string; title: string }andconst tasks = ref<Task[]>([]). Under the Clear button, add<SelectList :items="tasks">containing<template #item="{ item }">{{ item.title }}</template>and<template #empty>No tasks yet</template>, then a button:<button @click="tasks.push({ id: String(tasks.length + 1), title: 'Water the figs' })">Add a task</button>. The preview shows No tasks yet. Click Add a task and a Water the figs row takes its place. - In SelectList.vue, replace the Try it 2 comment with
const emit = defineEmits<{ select: [item: T] }>()and change the click to@click="selected = item; emit('select', item)". In App.vue, addconst last = ref(''), put@select="last = $event.name"on the fruit list and show<p>Last clicked: {{ last }}</p>. Click Melon: the line reads Last clicked: Melon. On the task list$eventis aTask, so there$event.titletype-checks and$event.namedoes not. - Change the Clear button to call
list?.reset(). Predict vue-tsc's message,Property 'reset' does not exist on type '{ clear: () => void; }'., then click Clear: the preview showsCannot read properties of undefined (reading 'call'), the playground's wording for calling a missing method (it rewrites?.into older JavaScript). Change it back toclear().