Componentes tipados con TypeScript

Intermedio
Vuejs
Vuejs
Actualizado: 27/03/2026

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 - Autor del tutorial

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