12.3 컴포넌트 Props/Emits 타입 — PropType과 복잡한 Props 패턴
Options API에서의 PropType
Options API를 사용하거나 복잡한 타입이 필요할 때 PropType을 사용합니다.
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',
},
},
})
복잡한 Props 패턴
중첩 객체 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>
함수 Props 타입
<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'
}
// 제네릭 타입은 withDefaults와 함께 쓸 수 없어서
// defineProps에서 직접 선언
interface Props {
data: unknown[]
columns: Column<unknown>[]
rowKey: string
onRowClick?: (row: unknown, event: MouseEvent) => void
}
const props = withDefaults(defineProps<Props>(), {
data: () => [],
columns: () => [],
})
</script>
유니온 타입 Props
<script setup lang="ts">
// 판별 유니온으로 다양한 상태 표현
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>
<!-- type에 따라 다른 UI -->
<button v-if="props.type === 'error' && props.retry" @click="props.retry">
다시 시도
</button>
<span v-if="props.type === 'error' && props.code">
오류 코드: {{ props.code }}
</span>
</div>
</template>
Emits 타입과 검증
<script setup lang="ts">
interface User {
id: string
name: string
}
// 이벤트 타입 정의
const emit = defineEmits<{
select: [user: User]
'update:selected': [ids: string[]]
delete: [id: string, reason?: string]
error: [error: Error]
}>()
// 검증 포함 (Options API 스타일)
// 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, '사용자 요청')
}
</script>
상위 컴포넌트 Props 전달 (v-bind="$attrs")
<!-- BaseInput.vue — HTML input 속성을 모두 전달 -->
<script setup lang="ts">
interface Props {
label: string
error?: string
}
defineProps<Props>()
// attrs가 root element가 아닌 input에 적용되도록
defineOptions({ inheritAttrs: false })
</script>
<template>
<div class="form-field">
<label>{{ label }}</label>
<!-- $attrs로 나머지 HTML 속성 전달 -->
<input v-bind="$attrs" :class="{ 'is-error': error }" />
<span v-if="error" class="error-text">{{ error }}</span>
</div>
</template>
<!-- 사용 -->
<BaseInput
label="이름"
type="text"
maxlength="50"
placeholder="이름을 입력하세요"
:error="nameError"
/>
슬롯 타입
<!-- DataTable.vue -->
<script setup lang="ts">
interface Props {
items: unknown[]
}
defineProps<Props>()
// 슬롯 타입 정의 (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">기본 로딩...</slot>
</div>
<div v-else-if="items.length === 0">
<slot name="empty">데이터 없음</slot>
</div>
<div v-else>
<slot
v-for="(item, index) in items"
:item="item"
:index="index"
/>
</div>
</div>
</template>
<!-- 사용 — 슬롯 props 타입 안전하게 접근 -->
<DataTable :items="users">
<template #header>
<h2>사용자 목록</h2>
</template>
<template #default="{ item, index }">
<UserRow :user="item as User" :index="index" />
</template>
<template #empty>
<p>등록된 사용자가 없습니다.</p>
</template>
</DataTable>
고수 팁
1. Props 타입 외부 파일로 분리
// 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. Props 기본값에 함수 사용
<script setup lang="ts">
interface Props {
items?: string[]
config?: { timeout: number; retries: number }
}
withDefaults(defineProps<Props>(), {
items: () => [], // ✅ 배열/객체는 팩토리 함수 사용
config: () => ({ timeout: 5000, retries: 3 }),
})
</script>