Component-level type safety is where most Vue + TypeScript codebases quietly degrade — props typed as any through a misused PropType, emits with no payload contract, injected values assumed non-null without a check. This module covers the compiler macros and patterns that keep the type system meaningful at every component boundary.


defineProps: Type-Only vs Runtime Declaration

defineProps supports two syntaxes: a runtime object (Vue 2-style, with type/required/default) and a pure TypeScript generic. The generic form is preferred in strict codebases because the compiler statically extracts required/optional and full literal types — no runtime PropType<T> cast that can silently drift from the real shape.

UserCard.vuevue
<script setup lang="ts">
interface UserCardProps {
  userId: string
  displayName: string
  role?: 'admin' | 'editor' | 'viewer'
  metadata?: Record<string, unknown>
}

// withDefaults is required for optional props that need a non-undefined default —
// plain defineProps<T>() cannot express a default value, only optionality.
const props = withDefaults(defineProps<UserCardProps>(), {
  role: 'viewer',
  metadata: () => ({}) // factory function required for object/array defaults —
                        // a bare object literal would be shared by reference across all instances
})
</script>

<template>
  <article class="user-card">
    <h3>{{ props.displayName }}</h3>
    <span class="user-card__role">{{ props.role }}</span>
  </article>
</template>

<style scoped>
.user-card { border: 1px solid #ddd; border-radius: 6px; padding: 1rem; }
.user-card__role { font-size: 0.75rem; text-transform: uppercase; color: #666; }
</style>

defineEmits: Strict Event Contracts

The modern (Vue 3.3+) emits syntax uses a type literal mapping event names to tuple argument types, replacing the older call-signature-union syntax. It reads closer to a function overload table and is what vue-tsc and the Volar language service resolve fastest.

PriceEditor.vuevue
<script setup lang="ts">
import { ref } from 'vue'

// Modern type-literal emits syntax: each key is an event name,
// each value is the tuple of argument types for that event.
const emit = defineEmits<{
  'update:price': [value: number]
  'validation-error': [message: string, field: string]
  submit: []
}>()

const localPrice = ref(0)

function commit(): void {
  if (localPrice.value < 0) {
    emit('validation-error', 'Price cannot be negative', 'price')
    return
  }
  emit('update:price', localPrice.value)
  emit('submit')
}
</script>

<template>
  <div class="price-editor">
    <input v-model.number="localPrice" type="number" />
    <button type="button" @click="commit">Save</button>
  </div>
</template>
FormPanel.vuevue
<script setup lang="ts">
import { ref } from 'vue'

const isValid = ref(true)

function validate(): boolean {
  isValid.value = Math.random() > 0.5 // placeholder validation result
  return isValid.value
}

function reset(): void {
  isValid.value = true
}

// Only these two are visible to a parent via a template ref —
// everything else in this setup scope remains private.
defineExpose({ validate, reset })
</script>

Vue 3.3+ Generic Components

The generic attribute on <script setup> declares type parameters scoped to that single component, letting defineProps, defineEmits, and slots all reference the same generic T — something impossible before 3.3, where components had no way to express "this prop's array element type determines this slot's parameter type".

GenericList.vuevue
<script setup lang="ts" generic="T extends { id: string | number }">
defineProps<{
  items: T[]
  activeId?: T['id']
}>()

defineEmits<{
  select: [item: T]
}>()

defineSlots<{
  default(props: { item: T; isActive: boolean }): unknown
  empty?(props: {}): unknown
}>()
</script>

<template>
  <ul class="generic-list">
    <li v-if="items.length === 0">
      <slot name="empty" />
    </li>
    <li
      v-for="item in items"
      :key="item.id"
      :class="{ 'generic-list__item--active': item.id === activeId }"
      @click="$emit('select', item)"
    >
      <slot :item="item" :is-active="item.id === activeId" />
    </li>
  </ul>
</template>

<style scoped>
.generic-list { list-style: none; padding: 0; margin: 0; }
.generic-list__item--active { background: #ecfdf5; }
</style>

Type-Safe Provide/Inject with InjectionKey

Plain string keys for provide/inject (provide('theme', ...)) give inject() no way to infer the value's type — it resolves to unknown unless you manually annotate every call site, and a typo in the key string fails silently at runtime with no compile-time signal. InjectionKey<T> (a branded Symbol) fixes both problems simultaneously.

injectionKeys.tstypescript
import type { InjectionKey, Ref } from 'vue'

export interface ThemeContext {
  mode: Ref<'light' | 'dark'>
  accentColor: Ref<string>
  toggleMode: () => void
}

// InjectionKey<T> is a Symbol at runtime but carries the type T at the type level —
// provide()/inject() overloads keyed on this type make the pairing type-safe end to end.
export const ThemeKey: InjectionKey<ThemeContext> = Symbol('theme-context')
ThemeProvider.vuevue
<script setup lang="ts">
import { ref, provide } from 'vue'
import { ThemeKey, type ThemeContext } from './injectionKeys'

const mode = ref<'light' | 'dark'>('light')
const accentColor = ref('#16a34a')

function toggleMode(): void {
  mode.value = mode.value === 'light' ? 'dark' : 'light'
}

const context: ThemeContext = { mode, accentColor, toggleMode }

// TypeScript enforces that `context` matches ThemeContext exactly —
// providing a mismatched shape here is a compile error, not a silent runtime bug.
provide(ThemeKey, context)
</script>

<template>
  <slot />
</template>
DeeplyNestedToggle.vuevue
<script setup lang="ts">
import { inject } from 'vue'
import { ThemeKey } from './injectionKeys'

// inject()'s return type is inferred as `ThemeContext | undefined` from the key —
// TypeScript forces a null check unless a default is supplied, because there is
// no static guarantee an ancestor actually called provide(ThemeKey, ...).
const theme = inject(ThemeKey)

if (!theme) {
  throw new Error('DeeplyNestedToggle must be used within a ThemeProvider')
}
</script>

<template>
  <button type="button" :style="{ color: theme.accentColor.value }" @click="theme.toggleMode">
    Switch to {{ theme.mode.value === 'light' ? 'dark' : 'light' }} mode
  </button>
</template>

Practical Example: Generic Autocomplete Dropdown

A reusable autocomplete must work for any item shape (users, products, tags) while still giving the consumer full type safety on the selected item and full control over how each option renders. This requires combining a generic type parameter, a strict props/emits/slots contract, and defineExpose for imperative focus control.

AutocompleteDropdown.vuevue
<script setup lang="ts" generic="T">
import { ref, computed, nextTick } from 'vue'

const props = defineProps<{
  items: T[]
  modelValue: T | null
  getLabel: (item: T) => string
  getKey: (item: T) => string | number
  placeholder?: string
}>()

const emit = defineEmits<{
  'update:modelValue': [value: T | null]
  query: [value: string]
}>()

defineSlots<{
  option(props: { item: T; label: string; isHighlighted: boolean }): unknown
  'no-results'?(props: { query: string }): unknown
}>()

const query = ref('')
const isOpen = ref(false)
const highlightedIndex = ref(-1)
const inputRef = ref<HTMLInputElement | null>(null)

const filteredItems = computed(() => {
  const q = query.value.trim().toLowerCase()
  if (!q) return props.items
  return props.items.filter((item) => props.getLabel(item).toLowerCase().includes(q))
})

function onInput(event: Event): void {
  query.value = (event.target as HTMLInputElement).value
  isOpen.value = true
  highlightedIndex.value = -1
  emit('query', query.value)
}

function selectItem(item: T): void {
  emit('update:modelValue', item)
  query.value = props.getLabel(item)
  isOpen.value = false
}

function moveHighlight(delta: number): void {
  if (!isOpen.value || filteredItems.value.length === 0) return
  const nextIndex = highlightedIndex.value + delta
  highlightedIndex.value = Math.max(0, Math.min(filteredItems.value.length - 1, nextIndex))
}

function confirmHighlighted(): void {
  const item = filteredItems.value[highlightedIndex.value]
  if (item) selectItem(item)
}

async function focus(): Promise<void> {
  isOpen.value = true
  await nextTick()
  inputRef.value?.focus()
}

defineExpose({ focus })
</script>

<template>
  <div class="autocomplete">
    <input
      ref="inputRef"
      :value="query"
      type="text"
      :placeholder="placeholder ?? 'Search...'"
      class="autocomplete__input"
      @input="onInput"
      @focus="isOpen = true"
      @keydown.down.prevent="moveHighlight(1)"
      @keydown.up.prevent="moveHighlight(-1)"
      @keydown.enter.prevent="confirmHighlighted"
      @keydown.esc="isOpen = false"
    />

    <ul v-if="isOpen" class="autocomplete__list">
      <li v-if="filteredItems.length === 0" class="autocomplete__empty">
        <slot name="no-results" :query="query">No results for "{{ query }}"</slot>
      </li>
      <li
        v-for="(item, index) in filteredItems"
        :key="getKey(item)"
        class="autocomplete__option"
        :class="{ 'autocomplete__option--highlighted': index === highlightedIndex }"
        @mousedown.prevent="selectItem(item)"
      >
        <slot name="option" :item="item" :label="getLabel(item)" :is-highlighted="index === highlightedIndex">
          {{ getLabel(item) }}
        </slot>
      </li>
    </ul>
  </div>
</template>

<style scoped>
.autocomplete { position: relative; max-width: 320px; font-family: system-ui, sans-serif; }
.autocomplete__input { width: 100%; padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; }
.autocomplete__list {
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  z-index: 10;
  margin: 0.25rem 0 0;
  padding: 0.25rem 0;
  list-style: none;
  background: #fff;
  border: 1px solid #ddd;
  border-radius: 4px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
  max-height: 240px;
  overflow-y: auto;
}
.autocomplete__option { padding: 0.5rem 0.75rem; cursor: pointer; }
.autocomplete__option--highlighted { background: #f0fdf4; }
.autocomplete__empty { padding: 0.5rem 0.75rem; color: #888; font-size: 0.875rem; }
</style>
ConsumerUsage.vuevue
<script setup lang="ts">
import { ref } from 'vue'
import AutocompleteDropdown from './AutocompleteDropdown.vue'

interface Product {
  sku: string
  name: string
  price: number
}

const products: Product[] = [
  { sku: 'A1', name: 'Wireless Mouse', price: 29.99 },
  { sku: 'B2', name: 'Mechanical Keyboard', price: 89.99 }
]

// selected is inferred as Product | null — T is resolved from :items binding below
const selected = ref<Product | null>(null)
</script>

<template>
  <AutocompleteDropdown
    v-model="selected"
    :items="products"
    :get-label="(p) => p.name"
    :get-key="(p) => p.sku"
    placeholder="Search products..."
  >
    <template #option="{ item, isHighlighted }">
      <strong>{{ item.name }}</strong>
      <span :style="{ opacity: isHighlighted ? 1 : 0.6 }">— ${{ item.price.toFixed(2) }}</span>
    </template>
  </AutocompleteDropdown>
</template>

Module 3 Checkpoint

  1. Q1. Why does an object or array default value in withDefaults() need to be a factory function?

  2. Q2. What problem does InjectionKey<T> solve that a plain string key for provide/inject does not?