Vue 3 con TypeScript en 2026: props, emits y composables con tipos seguros

Patrones de Vue 3 y TypeScript para 2026: defineProps, defineEmits, composables, defineModel y provide/inject con tipos seguros, ejemplos prácticos y preguntas de entrevista.

Vue 3 TypeScript: props, emits y composables con tipos seguros

Vue 3 con TypeScript en 2026 ofrece a los componentes una verificación estática completa de tipos en props, eventos y lógica reutilizable. La sintaxis <script setup>, combinada con macros del compilador como defineProps y defineEmits, convierte lo que antes eran definiciones validadas solo en tiempo de ejecución en contratos verificados en tiempo de compilación. Esta guía cubre los patrones tipados que todo desarrollador Vue necesita tanto para el código de producción como para las entrevistas técnicas.

Props tipadas en una sola línea

Desde Vue 3.5+, la forma genérica defineProps<T>() infiere los tipos de las props directamente de una interfaz de TypeScript: sin campos type en tiempo de ejecución ni casts con PropType. Combinada con la desestructuración reactiva de props, los valores por defecto quedan en línea: const { size = 'md' } = defineProps<Props>().

Tipar props con defineProps e interfaces de TypeScript

La declaración de props en tiempo de ejecución (props: { title: String }) funciona, pero duplica la información de tipos y pierde precisión. Los tipos unión, los objetos anidados y las firmas de función no pueden expresarse de forma limpia. La forma genérica de defineProps resuelve esto tomando la estructura de un tipo de TypeScript, que el compilador de Vue convierte después automáticamente en la declaración de ejecución correcta.

vue
<!-- UserCard.vue -->
<script setup lang="ts">
interface Props {
  userId: number            // required primitive
  name: string
  role: 'admin' | 'member'  // union type, impossible with runtime props
  tags?: string[]           // optional array
  onSelect?: (id: number) => void  // typed callback prop
}

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

<template>
  <article @click="props.onSelect?.(props.userId)">
    <h3>{{ props.name }}</h3>
    <span>{{ props.role }}</span>
  </article>
</template>

La prop role queda ahora restringida a dos cadenas literales, de modo que pasar role="guest" hace fallar la compilación. Esta es la razón principal para recurrir a defineProps con TypeScript: la guía oficial de TypeScript de Vue trata la forma genérica como la opción por defecto en los proyectos con <script setup>.

Valores por defecto: withDefaults frente a desestructuración reactiva

Las props únicamente tipadas no tienen por sí solas un mecanismo de valores por defecto en tiempo de ejecución. Históricamente la respuesta era withDefaults, que envuelve la macro y fusiona un objeto de valores por defecto:

vue
<!-- Button.vue -->
<script setup lang="ts">
interface Props {
  variant?: 'primary' | 'ghost'
  size?: 'sm' | 'md' | 'lg'
  disabled?: boolean
}

// withDefaults keeps reactivity and applies fallback values
const props = withDefaults(defineProps<Props>(), {
  variant: 'primary',
  size: 'md',
  disabled: false,
})
</script>

Vue 3.5 estabilizó la desestructuración reactiva de props, que hoy es el patrón más conciso. Desestructurar el resultado de defineProps y asignar un valor por defecto en la misma instrucción sigue siendo totalmente reactivo: el compilador reescribe cada acceso hacia props.x por debajo.

vue
<!-- Badge.vue -->
<script setup lang="ts">
interface Props {
  label: string
  color?: 'green' | 'red'
  outlined?: boolean
}

// Defaults are declared inline; each variable stays reactive
const { label, color = 'green', outlined = false } = defineProps<Props>()
</script>

<template>
  <span :class="[color, { outlined }]">{{ label }}</span>
</template>
La trampa de reactividad con props desestructuradas

Las props desestructuradas siguen siendo reactivas en el template y en un computed, pero pasar un valor desestructurado directamente a watch o a un composable lo lee una sola vez y rompe el enlace reactivo. Hay que envolverlo en un getter — watch(() => color, ...) — o convertirlo con toRef(props, 'color') cuando se necesita una ref más adelante.

Es una trampa habitual en las entrevistas: los candidatos suponen que la variable desestructurada es un valor plano, cuando en realidad el compilador ha redirigido cada lectura hacia props.color.

Emits con tipos seguros mediante defineEmits

Los eventos merecen el mismo rigor que las props. La forma genérica de defineEmits describe cada nombre de evento y su carga útil como una tupla, dando al componente padre autocompletado y al componente hijo la garantía, en tiempo de compilación, de que se emiten los argumentos correctos.

vue
<!-- SearchInput.vue -->
<script setup lang="ts">
// Tuple syntax: event name -> [ ...payload types ]
const emit = defineEmits<{
  search: [query: string]
  clear: []                       // no payload
  select: [id: number, label: string]  // multiple args
}>()

function onSubmit(value: string) {
  emit('search', value)   // ✅ typed
  // emit('search', 42)   // ❌ compile error: number not assignable to string
}
</script>

Esta forma de tupla reemplazó la antigua sintaxis por firma de llamada ((e: 'search', q: string): void) porque se lee mejor y admite varios eventos sin sobrecargas. Cuando un evento no transporta datos, una tupla vacía [] lo documenta de forma explícita. Combinar emits tipados con props tipadas produce componentes cuya interfaz pública completa es verificable antes de la ejecución: la misma disciplina que se aborda en la guía de la API de composición de Vue.

Tipar composables para lógica reutilizable

Los composables son funciones normales, así que siguen las reglas ordinarias de TypeScript, pero unas pocas convenciones los mantienen ergonómicos. Conviene anotar explícitamente los tipos Ref cuando la inferencia no es evidente, y usar genéricos cuando un composable envuelve datos arbitrarios como la respuesta de una API.

useFetch.tstypescript
import { ref, type Ref } from 'vue'

interface UseFetchReturn<T> {
  data: Ref<T | null>
  error: Ref<Error | null>
  loading: Ref<boolean>
}

// Generic <T> flows through to the caller's typed data
export function useFetch<T>(url: string): UseFetchReturn<T> {
  const data = ref<T | null>(null) as Ref<T | null>
  const error = ref<Error | null>(null)
  const loading = ref(true)

  fetch(url)
    .then((r) => r.json())
    .then((json: T) => { data.value = json })
    .catch((e: Error) => { error.value = e })
    .finally(() => { loading.value = false })

  return { data, error, loading }
}

En el punto de llamada, el parámetro genérico hace que data quede totalmente tipado sin ninguna anotación adicional:

UserList.vue (script setup)typescript
interface User { id: number; name: string }

// data is Ref<User[] | null> — inferred from the generic
const { data: users, loading } = useFetch<User[]>('/api/users')

Anotar explícitamente el objeto de retorno (UseFetchReturn<T>) vale esas pocas líneas extra: documenta el contrato, evita fugas accidentales de refs internas y da a los consumidores un único tipo que importar. Para patrones de composables más avanzados, como las sobrecargas de argumentos y la limpieza ligada al ciclo de vida, consulta la guía avanzada de composables de Vue.

¿Listo para aprobar tus entrevistas de Vue.js / Nuxt.js?

Practica con nuestros simuladores interactivos, flashcards y tests técnicos.

Tipar defineModel para el enlace bidireccional

defineModel, estable desde Vue 3.4, condensa el antiguo par de prop modelValue más emit update:modelValue en una única ref escribible. Su parámetro genérico tipa ambos sentidos del enlace a la vez.

vue
<!-- CurrencyInput.vue -->
<script setup lang="ts">
// model is Ref<number>; parent v-model is type-checked as number
const model = defineModel<number>({ required: true })

function increment() {
  model.value++   // writing back updates the parent
}
</script>

<template>
  <input :value="model" type="number" @input="model = Number(($event.target as HTMLInputElement).value)" />
  <button @click="increment">+1</button>
</template>

Los modelos con nombre — defineModel<string>('title') — se corresponden con v-model:title y reciben el mismo tipado. Esto elimina toda una categoría de errores de carga útil incoherente que el patrón manual de prop y emit solía ocultar.

Tipar refs de template e instancias de componente

Acceder a un nodo del DOM o a un componente hijo mediante una ref es donde el código Vue sin tipos recae con más frecuencia en any. La solución consiste en parametrizar useTemplateRef (Vue 3.5+) o la propia ref con el tipo del elemento, de modo que el acceso a las propiedades se verifique contra la interfaz real del DOM.

vue
<!-- FocusField.vue -->
<script setup lang="ts">
import { useTemplateRef, onMounted } from 'vue'

// The ref is typed as HTMLInputElement | null
const inputRef = useTemplateRef<HTMLInputElement>('field')

onMounted(() => {
  // .focus() and .select() are known because the element type is explicit
  inputRef.value?.focus()
})
</script>

<template>
  <input ref="field" placeholder="Type to search" />
</template>

Para una referencia a un componente hijo, InstanceType<typeof Child> extrae el tipo público del componente, exponiendo todo lo que el hijo declaró mediante defineExpose. Así, las llamadas a métodos del padre hacia el hijo quedan totalmente verificadas en lugar de adivinadas.

vue
<!-- Parent.vue -->
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import Modal from './Modal.vue'

// Ref is typed to Modal's exposed API (e.g. open / close methods)
const modal = useTemplateRef<InstanceType<typeof Modal>>('modal')

function launch() {
  modal.value?.open()   // ✅ checked against Modal's defineExpose
}
</script>

provide e inject con tipos seguros usando InjectionKey

La inyección de dependencias a través del árbol de componentes pierde la información de tipos a menos que la clave la transporte. InjectionKey<T> es un símbolo tipado que vincula el tipo de un valor a su clave, de modo que provide e inject permanecen sincronizados sin casts manuales.

theme-key.tstypescript
import type { InjectionKey, Ref } from 'vue'

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

// The key permanently associates the ThemeContext type with this symbol
export const ThemeKey: InjectionKey<ThemeContext> = Symbol('theme')
typescript
// provider (script setup) — value must match ThemeContext or it fails to compile
provide(ThemeKey, { mode, toggle })

// consumer — theme is inferred as ThemeContext | undefined
const theme = inject(ThemeKey)
theme?.toggle()

Proporcionar un valor cuya forma no coincida con ThemeContext provoca un error de compilación en el punto de inyección, capturando cualquier desajuste entre componentes distantes antes de que llegue a producción.

Preguntas frecuentes de entrevista sobre Vue y TypeScript

Los entrevistadores sondean si el candidato comprende la frontera entre los tipos de compilación y el comportamiento en tiempo de ejecución. Algunas preguntas recurrentes y sus respuestas precisas:

  • ¿Por qué preferir defineProps<T>() a la forma objeto de ejecución? Las props genéricas expresan tipos unión, firmas de función y estructuras anidadas que las declaraciones de ejecución no pueden capturar, y eliminan la duplicación entre la definición de tipo y la de ejecución.
  • ¿Son reactivas las props desestructuradas? Sí desde Vue 3.5+, porque el compilador reescribe el acceso hacia props.x. Pero un valor desestructurado pasado a watch o a un composable se lee una sola vez: hay que usar un getter o toRef.
  • ¿Cómo se tipa un evento emitido sin carga útil? Con una tupla vacía: defineEmits<{ close: [] }>().
  • ¿Qué reemplaza defineModel? El par de prop modelValue y emit update:modelValue, unificado en una única ref escribible tipada.

Practicar con un banco de preguntas real afina los reflejos que las entrevistas premian: el módulo de entrevista sobre composables de Vue entrena precisamente estos patrones. El instrumental también importa: ejecutar vue-tsc en la CI para que los errores de tipos bloqueen los merges, y apoyarse en el manual de TypeScript cuando entran en juego los genéricos de los composables. La experiencia en el editor se apoya en el instrumental oficial Volar de Vue, que lee estas macros para señalar los errores en línea.

Conclusión

  • Usar la forma genérica defineProps<Props>() para expresar tipos unión, campos opcionales y props callback que las declaraciones de ejecución no pueden capturar.
  • Preferir la desestructuración reactiva de props con valores por defecto en línea desde Vue 3.5+, y recurrir a withDefaults solo cuando un objeto de valores por defecto compartido resulte más claro.
  • Tipar los eventos con la forma de tupla de defineEmits para que las cargas útiles se verifiquen en tiempo de compilación tanto en el hijo como en el padre.
  • Anotar explícitamente los tipos de retorno de los composables y usar genéricos para reenviar de extremo a extremo los tipos de datos que proporciona quien llama.
  • Adoptar defineModel<T>() para el enlace bidireccional y así condensar el boilerplate de prop más emit en una única ref tipada.
  • Ejecutar vue-tsc en integración continua para que las regresiones de tipos hagan fallar la compilación en lugar de llegar a producción.

¡Empieza a practicar!

Pon a prueba tu conocimiento con nuestros simuladores de entrevista y tests técnicos.

Etiquetas

#Vue 3
#TypeScript
#defineProps
#Composables
#Frontend

Compartir

Artículos relacionados