Typing Composables
Type what a composable returns, make it generic when values pass through, and accept arguments that may or may not be reactive.
function useThing<T>(source: MaybeRefOrGetter<T>): UseThing<T>A composable is a plain function that uses Vue's reactivity and returns refs or functions for a component to use. Nothing in it is special to TypeScript: its types are its signature, and that signature is the whole contract its callers see.
The return type
Inference is enough while a composable lives next to its only caller. Once it is exported and used in several places, declare the return as an exported interface. Hovering a destructured name then shows something meaningful, and a change that breaks callers is reported at the composable rather than scattered across the components that use it.
Arguments that may be reactive
Callers want to pass a plain value sometimes and a ref another time. MaybeRef<T>, available from 3.3, is an alias for T | Ref<T>. MaybeRefOrGetter<T> is T | Ref<T> | (() => T). Read such an argument with toValue(source): it returns a plain value as is, unwraps a ref, and calls a getter. The older unref does the first two but not the third.
Generic composables
A composable that passes values through should be generic: useSelection<T>(items: MaybeRefOrGetter<T[]>). TypeScript cannot resolve ref's unwrapping over an unresolved parameter, so annotate internal state as Ref<T[]> rather than relying on inference there.
What trips people up
A MaybeRef argument read without toValue may be a ref object, not the value, and comparisons against it quietly fail.
Example
// src/composables/useSelection.ts
import { computed, ref, toValue } from 'vue'
import type { ComputedRef, MaybeRefOrGetter, Ref } from 'vue'
export interface UseSelection<T> {
selected: Ref<T[]>
isSelected: (item: T) => boolean
toggle: (item: T) => void
allSelected: ComputedRef<boolean>
}
export function useSelection<T>(items: MaybeRefOrGetter<T[]>): UseSelection<T> {
// Annotated, because ref<T[]>([]) cannot be simplified for an unresolved T.
const selected = ref([]) as Ref<T[]>
function isSelected(item: T): boolean {
return selected.value.includes(item)
}
function toggle(item: T): void {
selected.value = isSelected(item)
? selected.value.filter((entry) => entry !== item)
: [...selected.value, item]
}
const allSelected = computed(() => {
const list = toValue(items)
return list.length > 0 && list.every(isSelected)
})
return { selected, isSelected, toggle, allSelected }
}
// const { toggle, isSelected, allSelected } = useSelection(['mix', 'master'])
// toggle('mix')
// console.log(isSelected('mix'), allSelected.value)
// Output: true falseWhen to use it
- Extracting selection, pagination or form state out of a component so two components can share it.
- Writing a helper that works whether the caller passes a ref or a plain value.
- Making a list helper reusable for any item type instead of copying it per type.
- Publishing a composable from a shared package, where callers only have its signature to go on.
More examples
A component using it, with T inferred
A list of tags that toggle between selected and not, with a count underneath. The argument is a ref, which is why the parameter is MaybeRefOrGetter: the composable's computed re-reads it, so adding a tag updates allSelected. Passing a plain array would work too, and would simply never change.
<script setup lang="ts">
import { ref } from 'vue'
import { useSelection } from '@/composables/useSelection'
interface Tag {
id: number
name: string
}
const tags = ref<Tag[]>([
{ id: 1, name: 'mix' },
{ id: 2, name: 'master' }
])
// T is inferred as Tag from the argument; nothing has to be written twice.
const { selected, isSelected, toggle, allSelected } = useSelection(tags)
</script>
<template>
<ul>
<li v-for="tag in tags" :key="tag.id">
<button type="button" @click="toggle(tag)">
{{ tag.name }}<span v-if="isSelected(tag)"> (selected)</span>
</button>
</li>
</ul>
<p v-if="allSelected">Everything is selected</p>
<p>{{ selected.length }} selected</p>
</template>
<style scoped>
ul {
padding-left: 0;
list-style: none;
}
</style>MaybeRef, MaybeRefOrGetter, toValue and unref
Both aliases arrived in 3.3, as did toValue. Prefer MaybeRefOrGetter: a getter is how a caller passes a derived value, such as one prop out of a destructured props object, without creating a computed first. unref is the older helper and still correct for a MaybeRef, but it returns a function unchanged rather than calling it.
// src/composables/useCharCount.ts
import { computed, toValue, unref } from 'vue'
import type { ComputedRef, MaybeRef, MaybeRefOrGetter } from 'vue'
// MaybeRef<string> is an alias for string | Ref<string>.
export function useCharCountRef(text: MaybeRef<string>): ComputedRef<number> {
return computed(() => unref(text).length)
}
// MaybeRefOrGetter<string> adds (() => string), which toValue calls for you.
export function useCharCount(text: MaybeRefOrGetter<string>): ComputedRef<number> {
return computed(() => toValue(text).length)
}
// const title = ref('master mix')
// const count = useCharCount(title)
// const fromGetter = useCharCount(() => title.value.trim())
// console.log(count.value, fromGetter.value)
// Output: 10 10An explicit return type and a typed options object
Two patterns worth copying. An options object with optional members and destructured defaults keeps the call site readable, and beats three positional arguments the caller has to remember the order of. The named return interface means the composable's shape is written once, checked against the implementation, and visible to every caller in the editor.
// src/composables/useCounter.ts
import { computed, ref } from 'vue'
import type { ComputedRef, Ref } from 'vue'
export interface UseCounterOptions {
start?: number
step?: number
max?: number
}
export interface UseCounter {
count: Ref<number>
atMax: ComputedRef<boolean>
increment: () => void
}
export function useCounter(options: UseCounterOptions = {}): UseCounter {
const { start = 0, step = 1, max = Number.POSITIVE_INFINITY } = options
const count = ref(start)
const atMax = computed(() => count.value >= max)
function increment(): void {
if (atMax.value) return
count.value = Math.min(count.value + step, max)
}
return { count, atMax, increment }
}
// const { count, atMax, increment } = useCounter({ start: 8, max: 9 })
// increment()
// console.log(count.value, atMax.value)
// Output: 9 true
Discussion