TypeScript con Vue 3: Composition API tipada, defineProps, defineEmits y ref

Vue 3 y TypeScript forman una combinación sólida cuando se usa la Composition API con <script setup>. El compilador de Vue entiende la sintaxis de tipo puro de TypeScript y genera las props y emits correctamente sin necesidad de helpers en tiempo de ejecución.

defineProps con sintaxis de tipo puro

La forma más limpia de tipar props es pasar la interfaz directamente como argumento de tipo a defineProps:

// Boton.vue
<script setup lang="ts">
interface Props {
  label: string;
  variant?: 'primary' | 'secondary' | 'danger';
  disabled?: boolean;
  count?: number;
}

// Sin valores por defecto:
const props = defineProps<Props>();

// Con valores por defecto usando withDefaults:
const props = withDefaults(defineProps<Props>(), {
  variant: 'primary',
  disabled: false,
  count: 0,
});
</script>

defineEmits con tipos

defineEmits acepta la misma sintaxis de tipo para definir los eventos y sus payloads:

<script setup lang="ts">
const emit = defineEmits<{
  click: [event: MouseEvent];
  update: [value: string, index: number];
  close: [];
}>();

// Uso tipado:
emit('update', 'nuevo valor', 0);
// emit('update', 42, 0); // Error: el primer argumento debe ser string
</script>

ref tipado con ref<T>

ref infiere el tipo cuando se inicializa con un valor, pero hay casos en los que conviene anotarlo explícitamente:

<script setup lang="ts">
import { ref } from 'vue';

// Inferido automáticamente:
const count = ref(0);              // Ref<number>
const mensaje = ref('hola');       // Ref<string>

// Anotado explícitamente (necesario cuando el valor inicial puede ser null):
const usuario = ref<{ nombre: string; edad: number } | null>(null);

// Más tarde:
usuario.value = { nombre: 'Ana', edad: 28 }; // OK
usuario.value = 'texto';                      // Error de tipo
</script>

reactive y sus limitaciones de tipo

reactive infiere el tipo del objeto completo. A diferencia de ref, no permite reasignar la referencia:

<script setup lang="ts">
import { reactive } from 'vue';

interface Estado {
  cargando: boolean;
  items: string[];
  error: string | null;
}

const estado = reactive<Estado>({
  cargando: false,
  items: [],
  error: null,
});

// Acceso con tipo seguro:
estado.cargando = true;
estado.items.push('item');
// estado.cargando = 'texto'; // Error
</script>

useTemplateRef para referencias al DOM

La API recomendada en Vue 3.5+ para referenciar elementos del DOM es useTemplateRef, que mantiene la asociación con el elemento de forma tipada:

<script setup lang="ts">
import { useTemplateRef, onMounted } from 'vue';

const inputRef = useTemplateRef<HTMLInputElement>('miInput');

onMounted(() => {
  inputRef.value?.focus(); // HTMLInputElement | null
});
</script>

<template>
  <input ref="miInput" type="text" />
</template>

defineExpose y los tipos del componente hijo

Cuando necesitas acceder a métodos o propiedades de un componente hijo desde el padre, defineExpose controla qué se expone y TypeScript infiere el tipo del ref del componente:

// Modal.vue
<script setup lang="ts">
import { ref } from 'vue';

const visible = ref(false);
const abrir = () => { visible.value = true; };
const cerrar = () => { visible.value = false; };

defineExpose({ abrir, cerrar });
</script>

// Padre.vue
<script setup lang="ts">
import { useTemplateRef } from 'vue';
import Modal from './Modal.vue';

const modalRef = useTemplateRef<InstanceType<typeof Modal>>('modal');

const abrirModal = () => {
  modalRef.value?.abrir(); // tipado correctamente
};
</script>

<template>
  <Modal ref="modal" />
</template>

InjectionKey para provide/inject tipado

InjectionKey es un símbolo genérico que conecta el tipo entre provide e inject de forma segura:

// claves.ts
import type { InjectionKey } from 'vue';

interface Tema {
  color: string;
  modo: 'claro' | 'oscuro';
}

export const temaKey: InjectionKey<Tema> = Symbol('tema');

// Componente padre (provide):
import { provide } from 'vue';
import { temaKey } from './claves';

provide(temaKey, { color: '#3b82f6', modo: 'claro' });

// Componente hijo (inject):
import { inject } from 'vue';
import { temaKey } from './claves';

const tema = inject(temaKey);
// tema es Tema | undefined  (o Tema si se pasa valor por defecto)

const temaConDefault = inject(temaKey, { color: '#000', modo: 'oscuro' as const });
// temaConDefault es Tema

Errores frecuentes

El error más habitual al combinar Vue 3 y TypeScript es pasar un objeto de opciones a defineProps en lugar de la sintaxis de tipo puro, lo que obliga a duplicar la información de tipos en runtime. Otro problema común es no anotar el tipo en ref cuando el valor inicial es null o un array vacío: TypeScript infiere Ref<null> o Ref<never[]> en lugar del tipo correcto.

Imagen: Pexels / Nemuel Sereti

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP