Skip to main content
Advertisement

12.3 Component Props/Emits Types — PropType and Complex Props Patterns

PropType in Options API

Use PropType when using Options API or when complex types are needed.

import { defineComponent, PropType } from 'vue'

interface User {
id: string
name: string
email: string
}

export default defineComponent({
props: {
user: {
type: Object as PropType<User>,
required: true,
},
items: {
type: Array as PropType<string[]>,
default: () => [],
},
callback: {
type: Function as PropType<(id: string) => void>,
required: false,
},
variant: {
type: String as PropType<'primary' | 'secondary' | 'danger'>,
default: 'primary',
},
},
})

Complex Props Patterns

Nested Object Props

<script setup lang="ts">
interface Address {
street: string
city: string
country: string
zipCode?: string
}

interface UserProps {
user: {
id: string
name: string
email: string
address?: Address
roles: ('admin' | 'user' | 'guest')[]
metadata?: Record<string, unknown>
}
}

const props = defineProps<UserProps>()
</script>

<template>
<div>
<h2>{{ props.user.name }}</h2>
<p v-if="props.user.address">
{{ props.user.address.city }}, {{ props.user.address.country }}
</p>
<ul>
<li v-for="role in props.user.roles" :key="role">{{ role }}</li>
</ul>
</div>
</template>

Function Props Types

<script setup lang="ts">
interface TableProps<T> {
data: T[]
columns: Column<T>[]
rowKey: keyof T
onRowClick?: (row: T, event: MouseEvent) => void
onSort?: (key: keyof T, direction: 'asc' | 'desc') => void
renderCell?: (key: keyof T, value: T[keyof T], row: T) => string
}

interface Column<T> {
key: keyof T
label: string
sortable?: boolean
align?: 'left' | 'center' | 'right'
}

// Generic types can't be used with withDefaults, so
// declare directly in defineProps
interface Props {
data: unknown[]
columns: Column<unknown>[]
rowKey: string
onRowClick?: (row: unknown, event: MouseEvent) => void
}

const props = withDefaults(defineProps<Props>(), {
data: () => [],
columns: () => [],
})
</script>

Union Type Props

<script setup lang="ts">
// Use discriminated unions to represent various states
type AlertProps =
| { type: 'info'; message: string; closable?: boolean }
| { type: 'warning'; message: string; action?: string }
| { type: 'error'; message: string; code?: number; retry?: () => void }
| { type: 'success'; message: string; duration?: number }

const props = defineProps<AlertProps>()
</script>

<template>
<div :class="`alert alert-${props.type}`">
<p>{{ props.message }}</p>
<!-- Different UI based on type -->
<button v-if="props.type === 'error' && props.retry" @click="props.retry">
Retry
</button>
<span v-if="props.type === 'error' && props.code">
Error code: {{ props.code }}
</span>
</div>
</template>

Emits Types and Validation

<script setup lang="ts">
interface User {
id: string
name: string
}

// Define event types
const emit = defineEmits<{
select: [user: User]
'update:selected': [ids: string[]]
delete: [id: string, reason?: string]
error: [error: Error]
}>()

// With validation (Options API style)
// const emit = defineEmits({
// select: (user: User) => {
// return user.id && user.name
// },
// })

function selectUser(user: User) {
emit('select', user)
}

function deleteUser(id: string) {
emit('delete', id, 'User request')
}
</script>

Passing Parent Component Props (v-bind="$attrs")

<!-- BaseInput.vue — forwards all HTML input attributes -->
<script setup lang="ts">
interface Props {
label: string
error?: string
}

defineProps<Props>()

// Apply attrs to input, not root element
defineOptions({ inheritAttrs: false })
</script>

<template>
<div class="form-field">
<label>{{ label }}</label>
<!-- Pass remaining HTML attributes with $attrs -->
<input v-bind="$attrs" :class="{ 'is-error': error }" />
<span v-if="error" class="error-text">{{ error }}</span>
</div>
</template>
<!-- Usage -->
<BaseInput
label="Name"
type="text"
maxlength="50"
placeholder="Enter your name"
:error="nameError"
/>

Slot Types

<!-- DataTable.vue -->
<script setup lang="ts">
interface Props {
items: unknown[]
}

defineProps<Props>()

// Define slot types (Vue 3.3+)
defineSlots<{
default(props: { item: unknown; index: number }): void
header(props: {}): void
empty(props: {}): void
loading(props: {}): void
}>()
</script>

<template>
<div>
<slot name="header" />
<div v-if="isLoading">
<slot name="loading">Default loading...</slot>
</div>
<div v-else-if="items.length === 0">
<slot name="empty">No data</slot>
</div>
<div v-else>
<slot
v-for="(item, index) in items"
:item="item"
:index="index"
/>
</div>
</div>
</template>
<!-- Usage — type-safe slot prop access -->
<DataTable :items="users">
<template #header>
<h2>User List</h2>
</template>
<template #default="{ item, index }">
<UserRow :user="item as User" :index="index" />
</template>
<template #empty>
<p>No users registered.</p>
</template>
</DataTable>

Pro Tips

1. Separate Props types into external files

// types/props.ts
export interface ButtonProps {
label: string
variant?: 'primary' | 'secondary' | 'ghost'
size?: 'sm' | 'md' | 'lg'
disabled?: boolean
loading?: boolean
onClick?: (event: MouseEvent) => void
}
<script setup lang="ts">
import type { ButtonProps } from '@/types/props'
const props = withDefaults(defineProps<ButtonProps>(), {
variant: 'primary',
size: 'md',
})
</script>

2. Using Factory Functions for Default Values

<script setup lang="ts">
interface Props {
items?: string[]
config?: { timeout: number; retries: number }
}

withDefaults(defineProps<Props>(), {
items: () => [], // ✅ Arrays/objects use factory functions
config: () => ({ timeout: 5000, retries: 3 }),
})
</script>
Advertisement