Typing defineProps and defineEmits covers the data that flows into and out of a component. Two other channels deserve the same care: the props a component passes to its slots, and the methods a parent calls on a child through a template ref. Declared types turn both into a contract that vue-tsc checks on each side, so a mistake in the child or in the parent fails the type check instead of turning up in the browser.
Typed slot props with defineSlots
A list component that lets the parent decide how each row looks is the classic scoped slot: the child loops, the parent renders. Make it generic so it works for any item with an id:
<script setup lang="ts" generic="T extends { id: string | number }">
defineProps<{ items: T[] }>()
defineSlots<{
default(props: { item: T; index: number }): any
empty?(): any
}>()
</script>
<template>
<ul v-if="items.length">
<li v-for="(item, index) in items" :key="item.id">
<slot :item="item" :index="index" />
</li>
</ul>
<slot v-else name="empty" />
</template>
generic="T extends ..." declares a type parameter for the whole component. defineSlots takes only a type: each key is a slot name, and the first argument of each function is the props that slot receives. The return type is ignored for now, so any is what the Vue docs use. The question mark makes empty optional, so a parent does not have to fill it.
In the parent, T is inferred from the items you pass, so the slot props arrive fully typed:
<script setup lang="ts">
import DataList from '../components/DataList.vue'
type Task = { id: number; title: string; done: boolean }
const tasks: Task[] = [
{ id: 1, title: 'Read the lesson', done: true },
{ id: 2, title: 'Take the quiz', done: false },
]
</script>
<template>
<DataList :items="tasks">
<template #default="{ item, index }">
{{ index + 1 }}. {{ item.title }} <span v-if="item.done">(done)</span>
</template>
<template #empty>Nothing to do.</template>
</DataList>
</template>
Mistype item.titel and vue-tsc stops the build with Property 'titel' does not exist on type 'Task'. Did you mean 'title'?.
The Vue language tools can infer slot props from the <slot> tags alone. Declaring them still pays, because defineSlots turns that inference into a contract, and the child is checked against it too. Drop :index from the <slot> tag and the error appears in DataList.vue itself: Argument of type '{ item: T; }' is not assignable to parameter of type '{ item: T; index: number; }'.
The macro also returns a typed slots object. Keep it (const slots = defineSlots<...>()) when script code needs to check whether a slot was filled, such as slots.empty.
A small public API with defineExpose
A component written with <script setup> is closed by default. A parent that holds a ref to it sees none of its variables or functions. To open a door, you call defineExpose with exactly what the parent may use.
Focusing an input is the textbook case, because focus is something you do, not a value you pass:
<script setup lang="ts">
import { useTemplateRef } from 'vue'
const query = defineModel<string>({ default: '' })
const inputEl = useTemplateRef('input')
function focus() {
inputEl.value?.focus()
}
function clear() {
query.value = ''
focus()
}
defineExpose({ focus, clear })
</script>
<template>
<input ref="input" v-model="query" type="search" aria-label="Search tasks">
</template>
useTemplateRef('input') connects to the element with ref="input", and its type, HTMLInputElement | null, is inferred from the template. The value is null until the component mounts, hence the ?.. The exposed object has two functions. query stays private: the parent already controls it through v-model.
Call the child through a typed template ref
The parent uses the same useTemplateRef, this time on the component tag:
<script setup lang="ts">
import { onMounted, ref, useTemplateRef } from 'vue'
import SearchField from './components/SearchField.vue'
const query = ref('')
const search = useTemplateRef('search')
onMounted(() => {
search.value?.focus()
})
</script>
<template>
<SearchField ref="search" v-model="query" />
<button type="button" @click="search?.clear()">Clear</button>
</template>
In the template, search is unwrapped like any top-level ref, so the button calls search?.clear() without .value.
vue-tsc and your editor infer the type of a static template ref (a plain ref="search", not a bound :ref) from the tag it sits on. Here search.value is the child's public instance: the exposed focus and clear, plus the built-in $ properties every component has, such as $el. Try search.value?.query and you get Property 'query' does not exist on type ..., which is the closed-by-default rule enforced at compile time.
When you want the type spelled out, for example in a composable that receives the ref, use TypeScript's InstanceType on the component's type: useTemplateRef<InstanceType<typeof SearchField>>('search'). typeof SearchField is the component's constructor type, and InstanceType turns it into the type of what the ref holds. If any component will do and you only need shared properties such as $el, use ComponentPublicInstance from vue.
Type a generic component's ref with ComponentExposed
Give DataList an exposed method, say defineExpose({ scrollToTop }), and put ref="list" on its tag in TaskPage.vue. Inference still works: useTemplateRef('list') knows about scrollToTop with no extra types. Generic components only differ when you write the type yourself:
const list = useTemplateRef<InstanceType<typeof DataList>>('list')
vue-tsc rejects it with error TS2344, saying the type does not satisfy the constraint 'abstract new (...args: any) => any'. A generic component compiles to a generic function type, not a constructor, so InstanceType has nothing to work on. The Vue docs point to ComponentExposed from the vue-component-type-helpers package, which ships from the same repository as vue-tsc:
import { useTemplateRef } from 'vue'
import type { ComponentExposed } from 'vue-component-type-helpers'
import DataList from '../components/DataList.vue'
const list = useTemplateRef<ComponentExposed<typeof DataList>>('list')
function backToTop() {
list.value?.scrollToTop()
}
Install the package as a dev dependency with npm install -D vue-component-type-helpers. It contains types only. The same package has ComponentProps, ComponentSlots and ComponentEmit for the other channels.
Typing a dynamic component
<component :is> renders whichever component you give it. Type the value as Component from vue:
<script setup lang="ts">
import { ref, type Component } from 'vue'
import AlertsTab from './AlertsTab.vue'
import BillingTab from './BillingTab.vue'
import ProfileTab from './ProfileTab.vue'
type TabKey = 'profile' | 'billing' | 'alerts'
const tabs: Record<TabKey, { label: string; component: Component }> = {
profile: { label: 'Profile', component: ProfileTab },
billing: { label: 'Billing', component: BillingTab },
alerts: { label: 'Alerts', component: AlertsTab },
}
const active = ref<TabKey>('profile')
</script>
<template>
<button v-for="(tab, key) in tabs" :key="key" type="button" :aria-pressed="active === key" @click="active = key">
{{ tab.label }}
</button>
<component :is="tabs[active].component" />
</template>
The reactive state is the key, a string union, while the components sit in a plain constant. If you do store a component in state, use shallowRef<Component>(ProfileTab). A normal ref makes the component object deeply reactive, and Vue warns: Vue received a Component that was made a reactive object.
A template ref on a dynamic component cannot be inferred, because the tools do not know which component will render. Put ref="tab" on the <component> tag and the value of useTemplateRef('tab') is typed as a bare {}, with nothing on it to call, so write the union yourself: useTemplateRef<InstanceType<typeof ProfileTab> | InstanceType<typeof BillingTab>>('tab').
Prefer props and events to exposed state
A typed ref makes calling into a child feel safe, and that is the trap. Here a parent wants the Delete button disabled while a confirmation dialog is open, and the dialog only exposes open():
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import ConfirmDialog from '../components/ConfirmDialog.vue'
const dialog = useTemplateRef('dialog')
function deleteAccount() {
console.log('Account deleted')
}
</script>
<template>
<button type="button" :disabled="dialog?.isOpen" @click="dialog?.open()">Delete account</button>
<ConfirmDialog ref="dialog" @confirm="deleteAccount" />
</template>
vue-tsc reports Property 'isOpen' does not exist ... Did you mean 'open'?, and at runtime the button is never disabled. Exposing isOpen as well would silence the error, but then the open state lives in the child, and the parent reads it through a ref that is null on the first render.
Move the state up instead. Rewrite the dialog so it receives its open state through v-model:open, closes itself by setting the model to false, and emits confirm:
<script setup lang="ts">
const open = defineModel<boolean>('open', { required: true })
const emit = defineEmits<{ confirm: [] }>()
function confirm() {
emit('confirm')
open.value = false
}
</script>
<template>
<dialog :open="open">
<p>Delete your account?</p>
<button type="button" @click="open = false">Cancel</button>
<button type="button" @click="confirm">Delete</button>
</dialog>
</template>
required: true types open as boolean rather than boolean | undefined, and a parent that leaves out v-model:open fails the type check. The parent now owns the boolean:
<script setup lang="ts">
import { ref } from 'vue'
import ConfirmDialog from '../components/ConfirmDialog.vue'
const confirming = ref(false)
function deleteAccount() {
console.log('Account deleted')
}
</script>
<template>
<button type="button" :disabled="confirming" @click="confirming = true">Delete account</button>
<ConfirmDialog v-model:open="confirming" @confirm="deleteAccount" />
</template>
No ref, no null check, and the dialog can be tested on its own by passing a prop. Keep defineExpose for actions that have no natural state: focus an input, scroll a list, play a video, reset a form to its defaults.