Composables tipados con TypeScript

Intermedio
Vuejs
Vuejs
Actualizado: 27/03/2026

Tipado de ref, reactive y computed

Los primitivos de reactividad de Vue aceptan genéricos de TypeScript para anotar sus tipos de forma explícita. Aunque Vue infiere tipos automáticamente en la mayoría de casos, la anotación explícita es necesaria cuando el tipo inicial no representa el tipo completo.

ref con genéricos

<script setup lang="ts">
import { ref, type Ref } from 'vue'

// Tipo inferido: Ref<number>
const count = ref(0)

// Tipo explícito cuando el valor inicial no basta
const user = ref<string | null>(null)

// Tipo complejo con interfaz
interface Notification {
  id: number
  message: string
  type: 'info' | 'warning' | 'error'
}

const notifications = ref<Notification[]>([])
</script>

El tipo Ref<T> se puede usar en firmas de funciones y composables para anotar parámetros y valores de retorno:

import { type Ref } from 'vue'

function useCounter(initial: number): { count: Ref<number>; increment: () => void } {
  const count = ref(initial)
  const increment = () => { count.value++ }
  return { count, increment }
}

reactive con genéricos

reactive infiere el tipo del objeto pasado, pero puede anotarse explícitamente con una interfaz:

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

interface FormState {
  username: string
  email: string
  age: number | null
  accepted: boolean
}

const form = reactive<FormState>({
  username: '',
  email: '',
  age: null,
  accepted: false
})
</script>

computed con genéricos

computed infiere su tipo del valor retornado por la función getter. Se puede anotar explícitamente cuando el tipo inferido no es suficientemente específico:

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

const items = ref([1, 2, 3, 4, 5])

// Inferido como ComputedRef<number>
const total = computed(() => items.value.reduce((a, b) => a + b, 0))

// Anotación explícita
const summary = computed<string>(() => `Total: ${total.value} (${items.value.length} items)`)
</script>

Composables con tipos de retorno explícitos

Un composable es una función que encapsula lógica reactiva reutilizable. Definir tipos de retorno explícitos mejora la mantenibilidad y documenta la API pública del composable:

import { ref, computed, type Ref, type ComputedRef } from 'vue'

interface UseCounterReturn {
  count: Ref<number>
  doubled: ComputedRef<number>
  increment: () => void
  decrement: () => void
  reset: () => void
}

export function useCounter(initialValue: number = 0): UseCounterReturn {
  const count = ref(initialValue)

  const doubled = computed(() => count.value * 2)

  function increment() {
    count.value++
  }

  function decrement() {
    count.value--
  }

  function reset() {
    count.value = initialValue
  }

  return { count, doubled, increment, decrement, reset }
}

Definir una interfaz para el retorno tiene ventajas claras:

  • El editor muestra documentación precisa al usar el composable
  • Si se cambia la implementación interna, el tipo de retorno garantiza que la API pública no se rompa
  • Facilita escribir tests con tipos exactos

MaybeRef y MaybeRefOrGetter

Vue proporciona tipos de utilidad para crear composables que aceptan argumentos tanto reactivos como no reactivos, maximizando la flexibilidad:

  • MaybeRef<T>: Acepta T o Ref<T>
  • MaybeRefOrGetter<T>: Acepta T, Ref<T> o () => T

Estos tipos permiten que un composable funcione correctamente sin importar cómo el consumidor pase los datos:

import { computed, toValue, type MaybeRefOrGetter } from 'vue'

export function useFormattedPrice(price: MaybeRefOrGetter<number>, currency: MaybeRefOrGetter<string> = 'EUR') {
  const formatted = computed(() => {
    const p = toValue(price)
    const c = toValue(currency)
    return new Intl.NumberFormat('es-ES', {
      style: 'currency',
      currency: c
    }).format(p)
  })

  return { formatted }
}

toValue para unwrapping

toValue() es la función complementaria que extrae el valor independientemente del formato de entrada:

  • Si recibe un Ref, retorna .value
  • Si recibe una función getter, la ejecuta y retorna el resultado
  • Si recibe un valor plano, lo retorna tal cual
import { ref, toValue, type MaybeRefOrGetter } from 'vue'

function useTitle(title: MaybeRefOrGetter<string>) {
  watchEffect(() => {
    document.title = toValue(title)
  })
}

// Todas estas llamadas son válidas:
useTitle('Mi página')
useTitle(ref('Mi página'))
useTitle(() => `Página ${route.name}`)

Esto es preferible a unref(), que solo maneja Ref pero no funciones getter.

Genéricos en composables

Los composables genéricos aceptan tipos como parámetros, permitiendo reutilizar la misma lógica con diferentes estructuras de datos.

useFetch genérico

import { ref, watchEffect, toValue, type Ref, type MaybeRefOrGetter } from 'vue'

interface UseFetchReturn<T> {
  data: Ref<T | null>
  error: Ref<string | null>
  isLoading: Ref<boolean>
}

export function useFetch<T>(url: MaybeRefOrGetter<string>): UseFetchReturn<T> {
  const data = ref<T | null>(null) as Ref<T | null>
  const error = ref<string | null>(null)
  const isLoading = ref(false)

  watchEffect(async () => {
    const resolvedUrl = toValue(url)
    if (!resolvedUrl) return

    isLoading.value = true
    error.value = null

    try {
      const response = await fetch(resolvedUrl)
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`)
      }
      data.value = await response.json() as T
    } catch (e) {
      error.value = e instanceof Error ? e.message : 'Error desconocido'
      data.value = null
    } finally {
      isLoading.value = false
    }
  })

  return { data, error, isLoading }
}

Uso del composable con tipo explícito:

<script setup lang="ts">
import { useFetch } from '@/composables/useFetch'

interface User {
  id: number
  name: string
  email: string
}

const { data: users, error, isLoading } = useFetch<User[]>('/api/users')
</script>

<template>
  <div v-if="isLoading">Cargando...</div>
  <div v-else-if="error">Error: {{ error }}</div>
  <ul v-else-if="users">
    <li v-for="user in users" :key="user.id">
      {{ user.name }} - {{ user.email }}
    </li>
  </ul>
</template>

useLocalStorage genérico

import { ref, watch, type Ref } from 'vue'

export function useLocalStorage<T>(key: string, defaultValue: T): Ref<T> {
  const stored = localStorage.getItem(key)
  const data = ref<T>(stored ? JSON.parse(stored) : defaultValue) as Ref<T>

  watch(
    data,
    (newValue) => {
      if (newValue === null || newValue === undefined) {
        localStorage.removeItem(key)
      } else {
        localStorage.setItem(key, JSON.stringify(newValue))
      }
    },
    { deep: true }
  )

  return data
}

Uso:

<script setup lang="ts">
import { useLocalStorage } from '@/composables/useLocalStorage'

interface UserPreferences {
  theme: 'light' | 'dark'
  language: string
  fontSize: number
}

const preferences = useLocalStorage<UserPreferences>('user-prefs', {
  theme: 'light',
  language: 'es',
  fontSize: 16
})
</script>

useDebounce genérico

import { ref, watch, type Ref, type MaybeRefOrGetter, toValue } from 'vue'

export function useDebounce<T>(source: MaybeRefOrGetter<T>, delay: number = 300): Ref<T> {
  const debounced = ref<T>(toValue(source)) as Ref<T>
  let timeout: ReturnType<typeof setTimeout>

  watch(
    () => toValue(source),
    (newValue) => {
      clearTimeout(timeout)
      timeout = setTimeout(() => {
        debounced.value = newValue
      }, delay)
    }
  )

  return debounced
}

Uso:

<script setup lang="ts">
import { ref } from 'vue'
import { useDebounce } from '@/composables/useDebounce'
import { useFetch } from '@/composables/useFetch'

const searchQuery = ref('')
const debouncedQuery = useDebounce(searchQuery, 500)

interface SearchResult {
  id: number
  title: string
}

const { data: results } = useFetch<SearchResult[]>(
  () => `/api/search?q=${debouncedQuery.value}`
)
</script>

<template>
  <input v-model="searchQuery" placeholder="Buscar..." />
  <ul v-if="results">
    <li v-for="result in results" :key="result.id">{{ result.title }}</li>
  </ul>
</template>

Tipado de provide/inject con InjectionKey

provide e inject transmiten datos entre componentes sin pasar por props. Con TypeScript, se usa InjectionKey<T> para garantizar la seguridad de tipos:

// src/keys.ts
import type { InjectionKey, Ref } from 'vue'

export interface ThemeConfig {
  primary: string
  secondary: string
  dark: boolean
}

export const themeKey: InjectionKey<Ref<ThemeConfig>> = Symbol('theme')
export const loggerKey: InjectionKey<(msg: string) => void> = Symbol('logger')

El proveedor usa la clave tipada:

<!-- App.vue -->
<script setup lang="ts">
import { ref, provide } from 'vue'
import { themeKey, loggerKey, type ThemeConfig } from '@/keys'

const theme = ref<ThemeConfig>({
  primary: '#3498db',
  secondary: '#2ecc71',
  dark: false
})

provide(themeKey, theme)
provide(loggerKey, (msg: string) => console.log(`[App] ${msg}`))
</script>

El consumidor inyecta con la misma clave y obtiene el tipo correcto:

<!-- DeepChild.vue -->
<script setup lang="ts">
import { inject } from 'vue'
import { themeKey, loggerKey } from '@/keys'

const theme = inject(themeKey)
// Tipo: Ref<ThemeConfig> | undefined

const logger = inject(loggerKey, (msg: string) => console.warn(msg))
// Tipo: (msg: string) => void — el valor por defecto elimina undefined
</script>

<template>
  <div :style="{ color: theme?.primary }">
    Componente con tema inyectado
  </div>
</template>

El segundo argumento de inject es un valor por defecto. Cuando se proporciona, TypeScript elimina undefined del tipo resultante, evitando comprobaciones de null innecesarias.

Ejemplo práctico: composable completo tipado

A continuación, un composable completo que combina genéricos, MaybeRefOrGetter, InjectionKey y tipos de retorno explícitos:

// src/composables/usePagination.ts
import { ref, computed, toValue, type Ref, type ComputedRef, type MaybeRefOrGetter } from 'vue'

interface UsePaginationReturn<T> {
  currentPage: Ref<number>
  pageSize: Ref<number>
  totalPages: ComputedRef<number>
  paginatedItems: ComputedRef<T[]>
  nextPage: () => void
  prevPage: () => void
  goToPage: (page: number) => void
}

export function usePagination<T>(
  items: MaybeRefOrGetter<T[]>,
  initialPageSize: number = 10
): UsePaginationReturn<T> {
  const currentPage = ref(1)
  const pageSize = ref(initialPageSize)

  const totalPages = computed(() =>
    Math.ceil(toValue(items).length / pageSize.value)
  )

  const paginatedItems = computed<T[]>(() => {
    const all = toValue(items)
    const start = (currentPage.value - 1) * pageSize.value
    return all.slice(start, start + pageSize.value)
  })

  function nextPage() {
    if (currentPage.value < totalPages.value) {
      currentPage.value++
    }
  }

  function prevPage() {
    if (currentPage.value > 1) {
      currentPage.value--
    }
  }

  function goToPage(page: number) {
    if (page >= 1 && page <= totalPages.value) {
      currentPage.value = page
    }
  }

  return {
    currentPage,
    pageSize,
    totalPages,
    paginatedItems,
    nextPage,
    prevPage,
    goToPage
  }
}

Uso en un componente:

<script setup lang="ts">
import { ref } from 'vue'
import { usePagination } from '@/composables/usePagination'

interface Product {
  id: number
  name: string
  price: number
}

const allProducts = ref<Product[]>([
  { id: 1, name: 'Producto A', price: 10 },
  { id: 2, name: 'Producto B', price: 20 },
  // ... más productos
])

const {
  currentPage,
  totalPages,
  paginatedItems,
  nextPage,
  prevPage,
  goToPage
} = usePagination<Product>(allProducts, 5)
</script>

<template>
  <ul>
    <li v-for="product in paginatedItems" :key="product.id">
      {{ product.name }} - {{ product.price }} €
    </li>
  </ul>
  <div>
    <button :disabled="currentPage === 1" @click="prevPage">Anterior</button>
    <span>Página {{ currentPage }} de {{ totalPages }}</span>
    <button :disabled="currentPage === totalPages" @click="nextPage">Siguiente</button>
  </div>
</template>

Este ejemplo demuestra cómo un composable genérico tipado con MaybeRefOrGetter acepta tanto datos reactivos como estáticos, y cómo el tipo de retorno explícito documenta su API completa.

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