defineProps con genéricos TypeScript
defineProps acepta un tipo genérico de TypeScript para definir las props de un componente. Esta es la forma recomendada y más expresiva de tipar props en Vue con TypeScript:
<script setup lang="ts">
const props = defineProps<{
title: string
count: number
items: string[]
active?: boolean
}>()
</script>
Las propiedades marcadas con ? son opcionales. TypeScript garantiza que el componente padre pase las props requeridas con los tipos correctos, y el editor mostrará errores si no se cumplen.
Tipos complejos en props
Se pueden usar interfaces y type aliases para organizar tipos de props más complejos:
<script setup lang="ts">
interface Author {
name: string
email: string
avatar?: string
}
interface ArticleProps {
title: string
content: string
author: Author
tags: string[]
publishedAt: Date
draft?: boolean
}
const props = defineProps<ArticleProps>()
</script>
Importar tipos para props
Los tipos pueden importarse desde archivos externos:
<script setup lang="ts">
import type { Product } from '@/types/product'
const props = defineProps<{
product: Product
showDetails?: boolean
}>()
</script>
Valores por defecto con destructuring
Vue permite asignar valores por defecto a las props opcionales mediante destructuring con valores predeterminados de JavaScript:
<script setup lang="ts">
interface Props {
title: string
count?: number
variant?: 'primary' | 'secondary' | 'danger'
items?: string[]
}
const {
title,
count = 0,
variant = 'primary',
items = () => []
} = defineProps<Props>()
</script>
Los valores por defecto de tipos objeto o array deben envolverse en una función factory para evitar que se compartan entre instancias. En el ejemplo, items usa () => [] para que cada instancia del componente reciba su propio array vacío.
Esta es la alternativa a withDefaults, que también sigue siendo válida:
<script setup lang="ts">
interface Props {
title: string
count?: number
items?: string[]
}
const props = withDefaults(defineProps<Props>(), {
count: 0,
items: () => []
})
</script>
defineEmits con TypeScript
defineEmits acepta un tipo genérico que define los eventos que el componente puede emitir, junto con los tipos de sus argumentos:
<script setup lang="ts">
const emit = defineEmits<{
(e: 'update', value: string): void
(e: 'delete', id: number): void
(e: 'submit'): void
}>()
function handleUpdate(newValue: string) {
emit('update', newValue)
}
function handleDelete(itemId: number) {
emit('delete', itemId)
}
</script>
TypeScript verifica que cada llamada a emit use un nombre de evento válido y pase argumentos del tipo correcto.
Sintaxis alternativa con named tuples
Existe una sintaxis más concisa usando named tuples:
<script setup lang="ts">
const emit = defineEmits<{
update: [value: string]
delete: [id: number]
submit: []
change: [oldValue: string, newValue: string]
}>()
</script>
Esta sintaxis es equivalente a la anterior y resulta más legible cuando hay múltiples eventos.
defineModel con TypeScript
defineModel simplifica la creación de componentes con v-model bidireccional. Con TypeScript, se anota el tipo del modelo:
<script setup lang="ts">
const modelValue = defineModel<string>()
</script>
<template>
<input
:value="modelValue"
@input="modelValue = ($event.target as HTMLInputElement).value"
/>
</template>
El componente padre lo usa con v-model:
<template>
<CustomInput v-model="username" />
</template>
Modelo con valor por defecto y requerido
<script setup lang="ts">
const modelValue = defineModel<string>({ required: true })
</script>
Múltiples modelos
Un componente puede exponer múltiples modelos con nombres:
<script setup lang="ts">
const firstName = defineModel<string>('firstName')
const lastName = defineModel<string>('lastName')
</script>
<template>
<input
:value="firstName"
@input="firstName = ($event.target as HTMLInputElement).value"
placeholder="Nombre"
/>
<input
:value="lastName"
@input="lastName = ($event.target as HTMLInputElement).value"
placeholder="Apellido"
/>
</template>
Uso en el padre:
<template>
<UserNameInput v-model:first-name="first" v-model:last-name="last" />
</template>
defineSlots para slots tipados
defineSlots permite declarar los slots que un componente acepta junto con los tipos de sus props de slot (scoped slots):
<script setup lang="ts">
interface Item {
id: number
name: string
}
const slots = defineSlots<{
default(props: { item: Item; index: number }): any
header(props: { title: string }): any
empty(): any
}>()
</script>
<template>
<div>
<div v-if="$slots.header">
<slot name="header" :title="'Lista de elementos'" />
</div>
<template v-if="items.length > 0">
<div v-for="(item, index) in items" :key="item.id">
<slot :item="item" :index="index" />
</div>
</template>
<template v-else>
<slot name="empty" />
</template>
</div>
</template>
Cuando el componente padre usa el slot, el editor ofrece autocompletado para las props del scoped slot:
<template>
<ItemList :items="products">
<template #default="{ item, index }">
<p>{{ index + 1 }}. {{ item.name }}</p>
</template>
<template #empty>
<p>No hay elementos</p>
</template>
</ItemList>
</template>
defineExpose para refs tipadas
defineExpose controla qué propiedades y métodos se exponen al componente padre cuando usa una template ref. Con TypeScript, las propiedades expuestas están tipadas:
<!-- ChildComponent.vue -->
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
function increment(): void {
count.value++
}
function reset(): void {
count.value = 0
}
defineExpose({
count,
increment,
reset
})
</script>
En el componente padre, se accede a las propiedades expuestas con una template ref tipada:
<script setup lang="ts">
import { ref } from 'vue'
import ChildComponent from './ChildComponent.vue'
const childRef = ref<InstanceType<typeof ChildComponent> | null>(null)
function handleClick() {
childRef.value?.increment()
console.log(childRef.value?.count)
}
</script>
<template>
<ChildComponent ref="childRef" />
<button @click="handleClick">Incrementar hijo</button>
</template>
InstanceType<typeof ChildComponent> extrae el tipo de la instancia del componente, proporcionando autocompletado para todas las propiedades expuestas.
Componentes genéricos
Vue permite crear componentes genéricos que funcionan con cualquier tipo de dato. Se usa el atributo generic en la etiqueta <script setup>:
<!-- GenericSelect.vue -->
<script setup lang="ts" generic="T extends { id: number; label: string }">
import { ref } from 'vue'
const props = defineProps<{
options: T[]
placeholder?: string
}>()
const emit = defineEmits<{
select: [option: T]
}>()
const selected = ref<T | null>(null) as Ref<T | null>
function handleSelect(option: T) {
selected.value = option
emit('select', option)
}
</script>
<template>
<div class="select">
<button
v-for="option in options"
:key="option.id"
:class="{ selected: selected?.id === option.id }"
@click="handleSelect(option)"
>
{{ option.label }}
</button>
</div>
</template>
El componente infiere el tipo T a partir de los datos que recibe:
<script setup lang="ts">
interface Country {
id: number
label: string
code: string
}
const countries: Country[] = [
{ id: 1, label: 'España', code: 'ES' },
{ id: 2, label: 'Francia', code: 'FR' },
{ id: 3, label: 'Alemania', code: 'DE' }
]
function onSelect(country: Country) {
console.log('Seleccionado:', country.code)
}
</script>
<template>
<GenericSelect :options="countries" @select="onSelect" />
</template>
Múltiples parámetros genéricos
Un componente puede aceptar múltiples parámetros de tipo:
<script setup lang="ts" generic="T, U extends string | number">
defineProps<{
items: T[]
keyField: U
}>()
</script>
Restricciones de genéricos con extends
El uso de extends permite restringir los tipos aceptados:
<script setup lang="ts" generic="T extends Record<string, any>">
const props = defineProps<{
data: T
fields: (keyof T)[]
}>()
</script>
Este componente solo acepta objetos y permite especificar qué campos mostrar con seguridad de tipos, ya que fields solo puede contener claves existentes en T.
Ejemplo práctico: formulario tipado completo
<script setup lang="ts">
interface FormField {
label: string
type: 'text' | 'email' | 'number' | 'password'
required?: boolean
}
interface FormData {
[key: string]: string | number
}
const props = defineProps<{
fields: Record<string, FormField>
submitLabel?: string
}>()
const emit = defineEmits<{
submit: [data: FormData]
cancel: []
}>()
const { submitLabel = 'Enviar' } = props
const model = defineModel<FormData>({ required: true })
function handleSubmit() {
emit('submit', model.value)
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<div v-for="(field, key) in fields" :key="key">
<label :for="String(key)">{{ field.label }}</label>
<input
:id="String(key)"
:type="field.type"
:required="field.required"
:value="model[String(key)]"
@input="model[String(key)] = ($event.target as HTMLInputElement).value"
/>
</div>
<button type="submit">{{ submitLabel }}</button>
<button type="button" @click="emit('cancel')">Cancelar</button>
</form>
</template>
Este ejemplo combina defineProps con genéricos, defineEmits tipados, defineModel con tipo explícito y destructuring con valor por defecto, mostrando cómo todos los mecanismos de tipado de Vue trabajan juntos en un componente real.
Alan Sastre
Ingeniero de Software y formador, CEO en CertiDevs
Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Vuejs es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.
Más tutoriales de Vuejs
Explora más contenido relacionado con Vuejs y continúa aprendiendo con nuestros tutoriales gratuitos.
Aprendizajes de esta lección
Cursos que incluyen esta lección
Esta lección forma parte de los siguientes cursos estructurados con rutas de aprendizaje