← Volver al blog

TypeScript avanzado en Vue 3: tipos que realmente ayudan

Cómo sacar partido real a TypeScript en Vue 3: props tipadas con interfaces, composables genéricos, utility types en la práctica y MaybeRefOrGetter. Sin `any`, sin casting innecesario.

TypeScript avanzado en Vue 3: tipos que realmente ayudan

Hay una versión de TypeScript en Vue que parece TypeScript pero no lo es. La reconoces cuando ves esto:

const props = defineProps({
  user: Object,
  size: String,
})

El compilador no se queja. Pero props.user es object | undefined — no sabe nada del usuario, no autocompleta los campos, no avisa si accedes a una propiedad que no existe. Es TypeScript de nombre, no de beneficio.

Lo que sigue son los patrones concretos que uso en producción: props con interfaces reales, composables genéricos, utility types aplicados a Vue y el patrón moderno para composables flexibles con MaybeRefOrGetter.


Props y emits con la API de tipos

La forma correcta de tipar props en Vue 3 es con defineProps<T>(), pasando una interfaz como genérico. El compilador infiere los tipos de cada prop, autocompleta en el template y avisa cuando falta algo.

// components/UserCard.vue
interface Props {
  user: User
  size?: 'sm' | 'md' | 'lg'
  loading?: boolean
}

const props = defineProps<Props>()

Cuando necesitas valores por defecto, withDefaults los añade sin perder el tipado:

const props = withDefaults(defineProps<Props>(), {
  size: 'md',
  loading: false,
})

Lo mismo aplica a los emits. La versión sin tipos acepta cualquier cosa:

// ❌ Antipatrón
const emit = defineEmits(['update:modelValue', 'close'])
emit('update:modelValue', { wrong: 'payload' }) // no hay error

Con la API de tipos, cada evento define exactamente qué argumentos acepta:

const emit = defineEmits<{
  'update:modelValue': [value: string]
  close: []
  error: [message: string, code: number]
}>()

emit('update:modelValue', 42) // TS error: el argumento debe ser string

Los nombres de los parámetros (value, message, code) son opcionales pero ayudan a documentar la intención. Aparecen en el tooltip cuando usas el componente desde fuera.


El ref sin tipo: el any que nadie ve

ref(null) infiere Ref<null> a partir del valor inicial. Como el tipo ya ha sido inferido, cualquier asignación posterior incompatible produce un error.

// ❌ Antipatrón
const user = ref(null)
user.value = { id: '1', name: 'Ana' } // TypeScript: error, null no es asignable a null

La solución es siempre tipar el ref en la declaración:

const user = ref<User | null>(null)
user.value = { id: '1', name: 'Ana' } // correcto
user.value?.name // autocompleta

Lo mismo con reactive:

// ❌ Antipatrón
const form = reactive({})

// ✅
interface FormState {
  name: string
  email: string
  message: string
}

const form = reactive<FormState>({
  name: '',
  email: '',
  message: '',
})

Composables genéricos

El patrón más útil: un composable que no sabe con qué tipo trabaja. El genérico <T> se propaga desde la llamada hacia los refs internos.

// composables/useAsync.ts
export function useAsync<T>(fn: () => Promise<T>) {
  const data = ref<T | null>(null)
  const loading = ref(false)
  const error = ref<string | null>(null)

  async function execute() {
    loading.value = true
    error.value = null
    try {
      data.value = await fn()
    } catch (e) {
      error.value = e instanceof Error ? e.message : 'Error desconocido'
    } finally {
      loading.value = false
    }
  }

  return { data, loading, error, execute }
}
// En el componente
const { data: user, loading, execute: loadUser } = useAsync<User>(
  () => $fetch('/api/users/me')
)

// user es Ref<User | null>
// loading es Ref<boolean>

El genérico se puede inferir cuando el tipo de la función lo determina:

async function fetchUser(): Promise<User> {
  return $fetch('/api/users/me')
}

const { data } = useAsync(fetchUser) // data es Ref<User | null>, sin necesidad de <User>

El mismo patrón funciona para composables de paginación, caché, formularios — cualquier caso donde la forma de los datos varía pero la mecánica es la misma.


Utility types en la práctica

Los utility types de TypeScript (Partial, Pick, Omit, ReturnType...) resuelven situaciones concretas en Vue sin duplicar interfaces.

Partial para estados de formulario:

interface User {
  id: string
  name: string
  email: string
  role: 'admin' | 'user'
}

// El formulario de edición no incluye id ni role
type UserEditForm = Partial<Pick<User, 'name' | 'email'>>

const form = reactive<UserEditForm>({})

ReturnType para compartir el tipo de un composable:

Cuando varios componentes consumen el mismo composable y necesitan pasarse su resultado como prop:

// composables/useCurrentUser.ts
export function useCurrentUser() {
  const user = ref<User | null>(null)
  // ...
  return { user, loading, error, load, update }
}

// Si otro componente necesita este tipo como prop:
type CurrentUser = ReturnType<typeof useCurrentUser>

interface Props {
  userContext: CurrentUser
}

InstanceType para acceder a las props de un componente:

import UserCard from './UserCard.vue'

// Reutilizar el tipo de props de UserCard en otro componente
type UserCardProps = InstanceType<typeof UserCard>['$props']

Útil cuando construyes componentes wrapper que pasan las mismas props que el componente base.


MaybeRefOrGetter: composables que aceptan refs o valores

Un composable que solo acepta un valor estático obliga a usar computed en el componente para pasarle datos reactivos. El tipo MaybeRefOrGetter<T> — junto con toValue() — resuelve esto:

// composables/useFormattedDate.ts
import { computed, toValue, type MaybeRefOrGetter } from 'vue'

export function useFormattedDate(
  date: MaybeRefOrGetter<Date | string>,
  locale: MaybeRefOrGetter<string> = 'es-ES'
) {
  return computed(() => {
    const d = new Date(toValue(date))
    return new Intl.DateTimeFormat(toValue(locale)).format(d)
  })
}

Ahora el composable acepta cualquiera de estas formas sin cambiar una línea:

// Valor estático
const formatted = useFormattedDate(new Date())

// Ref
const date = ref(new Date())
const formatted = useFormattedDate(date)

// Getter (computed o función)
const formatted = useFormattedDate(() => props.date)

toValue() desenvuelve el ref si es un ref, llama la función si es un getter, o devuelve el valor directamente si es un primitivo. Es el contrato moderno para composables de Vue que trabajan con datos externos.


Type narrowing en composables

Cuando un ref puede ser T | null, TypeScript no deja operar sobre él sin una comprobación previa. La forma idiomática es el early return:

export function useCurrentUser(repository: UserRepository) {
  const user = ref<User | null>(null)
  const loading = ref(false)

  async function update(name: string) {
    if (!user.value) return // narrowing: después de esta línea, user.value es User

    loading.value = true
    user.value = await repository.update(user.value.id, { name })
    loading.value = false
  }

  return { user, loading, update }
}

En el template, v-if actúa como guard automático:

<template>
  <div v-if="user">
    <!-- Aquí TypeScript sabe que user no es null -->
    <h1>{{ user.name }}</h1>
  </div>
</template>

Dentro del bloque v-if="user", Volar y TypeScript eliminan null del tipo. Sin casting, sin !.


computed: dejar que Vue infiera

Vue infiere automáticamente el tipo de un computed a partir de lo que devuelve la función. En la mayoría de los casos no necesitas anotar ComputedRef<T> manualmente.

const user = ref<User | null>(null)

// TypeScript infiere ComputedRef<string>
const displayName = computed(() => user.value?.name ?? 'Anónimo')

// Anotar el tipo solo cuando la lógica interna no deja claro qué se devuelve
const label = computed<string>(() => {
  if (!user.value) return 'Invitado'
  return user.value.role === 'admin' ? `${user.value.name} (admin)` : user.value.name
})

El segundo caso es el límite razonable: cuando hay varias ramas de retorno y quieres que el tipo sea explícito para el lector, no para el compilador.


Cuándo no sobretipar

TypeScript infiere bien en muchas situaciones. Añadir un tipo explícito donde la inferencia ya es correcta es ruido.

// TypeScript infiere string[] — el tipo explícito no añade nada
const names = ref(['Ana', 'Luis'])

// Sin elementos, TypeScript no sabe qué tipo tendrá el array
const names = ref<string[]>([])

La regla práctica: añade tipo explícito cuando el valor inicial no determina el tipo final. Un ref([]) necesita tipo. Un ref('hola') no.

Lo mismo con as:

// ❌ No usar as para silenciar errores
const user = data as User

// ✅ Validar en el límite (API, formulario) y tipar correctamente desde ahí
function mapToUser(raw: Record<string, unknown>): User {
  return {
    id: String(raw.id),
    name: String(raw.name),
    email: String(raw.email),
  }
}

as oculta el problema. El mapper lo resuelve.


Conclusión

El TypeScript que ayuda en Vue 3:

  1. Props con interfaces reales y defineProps<T>() — nunca Object o String en runtime validators
  2. Emits tipados con argumentos explícitos — el contrato del componente es legible desde fuera
  3. Refs siempre con tipo en la declaración — ref<T | null>(null), no ref(null)
  4. Composables genéricos con <T> — una sola implementación para múltiples formas de datos
  5. MaybeRefOrGetter + toValue() — composables que aceptan refs, getters y valores sin forzar al consumidor
  6. computed sin anotación manual salvo cuando el tipo de retorno no es obvio
  7. as solo en los límites del sistema, nunca para silenciar errores internos

El objetivo no es que TypeScript compile — es que el editor te diga lo que se puede hacer con cada valor antes de que lo pruebes en el navegador.

¿Preguntas o quieres ver algún caso concreto? Escríbeme a hola@miguel-jimenez.dev.