← Volver al blog

Advanced TypeScript in Vue 3: types that actually help

How to get real value from TypeScript in Vue 3: typed props with interfaces, generic composables, utility types in practice, and MaybeRefOrGetter. No `any`, no unnecessary casting.

Advanced TypeScript in Vue 3: types that actually help

There's a version of TypeScript in Vue that looks like TypeScript but isn't. You know it when you see this:

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

The compiler doesn't complain. But props.user is object | undefined — it knows nothing about the user, doesn't autocomplete fields, doesn't warn when you access a property that doesn't exist. TypeScript in name, not in benefit.

What follows are the concrete patterns I use in production: props with real interfaces, generic composables, utility types applied to Vue, and the modern pattern for flexible composables with MaybeRefOrGetter.


Props and emits with the type-based API

The right way to type props in Vue 3 is with defineProps<T>(), passing an interface as the generic. The compiler infers the type of each prop, autocompletes in the template, and warns when something is missing.

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

const props = defineProps<Props>()

When you need default values, withDefaults adds them without losing the types:

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

The same applies to emits. The untyped version accepts anything:

// ❌ Anti-pattern
const emit = defineEmits(['update:modelValue', 'close'])
emit('update:modelValue', { wrong: 'payload' }) // no error

With the type-based API, each event defines exactly what arguments it accepts:

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

emit('update:modelValue', 42) // TS error: argument must be string

Parameter names (value, message, code) are optional but help document intent. They appear in the tooltip when using the component from outside.


The untyped ref: the any nobody sees

ref(null) infers Ref<null> from the initial value. Since the type has already been inferred, any incompatible assignment afterwards produces an error.

// ❌ Anti-pattern
const user = ref(null)
user.value = { id: '1', name: 'Ana' } // TypeScript: error, null is not assignable to null

The fix is always to type the ref at declaration:

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

Same with reactive:

// ❌ Anti-pattern
const form = reactive({})

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

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

Generic composables

The most useful pattern: a composable that doesn't know what type it works with. The generic <T> propagates from the call site into the internal refs.

// 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 : 'Unknown error'
    } finally {
      loading.value = false
    }
  }

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

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

The generic can be inferred when the function's return type determines it:

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

const { data } = useAsync(fetchUser) // data is Ref<User | null>, no need for <User>

The same pattern works for pagination, caching, form composables — any case where the shape of the data varies but the mechanics are the same.


Utility types in practice

TypeScript's utility types (Partial, Pick, Omit, ReturnType...) solve concrete situations in Vue without duplicating interfaces.

Partial for form state:

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

// The edit form doesn't include id or role
type UserEditForm = Partial<Pick<User, 'name' | 'email'>>

const form = reactive<UserEditForm>({})

ReturnType for sharing a composable's type:

When several components consume the same composable and need to pass its result as a prop:

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

// If another component needs this type as a prop:
type CurrentUser = ReturnType<typeof useCurrentUser>

interface Props {
  userContext: CurrentUser
}

InstanceType for accessing a component's props:

import UserCard from './UserCard.vue'

// Reuse UserCard's prop types in another component
type UserCardProps = InstanceType<typeof UserCard>['$props']

Useful when building wrapper components that pass the same props as the base component.


MaybeRefOrGetter: composables that accept refs or values

A composable that only accepts a static value forces the component to use computed to pass reactive data. The type MaybeRefOrGetter<T> — together with toValue() — solves this:

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

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

Now the composable accepts any of these forms without changing a single line:

// Static value
const formatted = useFormattedDate(new Date())

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

// Getter (computed or function)
const formatted = useFormattedDate(() => props.date)

toValue() unwraps the ref if it's a ref, calls the function if it's a getter, or returns the value directly if it's a primitive. It's the modern contract for Vue composables that work with external data.


Type narrowing in composables

When a ref can be T | null, TypeScript won't let you operate on it without a prior check. The idiomatic approach is the 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: after this line, user.value is User

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

  return { user, loading, update }
}

In the template, v-if acts as an automatic guard:

<template>
  <div v-if="user">
    <!-- TypeScript knows user is not null here -->
    <h1>{{ user.name }}</h1>
  </div>
</template>

Inside the v-if="user" block, Volar and TypeScript remove null from the type. No casting, no !.


computed: let Vue infer

Vue automatically infers the type of a computed from what its function returns. In most cases you don't need to annotate ComputedRef<T> manually.

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

// TypeScript infers ComputedRef<string>
const displayName = computed(() => user.value?.name ?? 'Anonymous')

// Annotate the type only when the internal logic doesn't make the return type obvious
const label = computed<string>(() => {
  if (!user.value) return 'Guest'
  return user.value.role === 'admin' ? `${user.value.name} (admin)` : user.value.name
})

The second case is the reasonable limit: when there are multiple return branches and you want the type to be explicit for the reader, not for the compiler.


When not to over-type

TypeScript infers well in many situations. Adding an explicit type where inference is already correct is noise.

// TypeScript infers string[] — the explicit type adds nothing
const names = ref(['Ana', 'Luis'])

// No elements: TypeScript can't tell what type the array will hold
const names = ref<string[]>([])

The practical rule: add an explicit type when the initial value doesn't determine the final type. A ref([]) needs a type. A ref('hello') doesn't.

Same with as:

// ❌ Don't use as to silence errors
const user = data as User

// ✅ Validate at the boundary (API, form) and type correctly from there
function mapToUser(raw: Record<string, unknown>): User {
  return {
    id: String(raw.id),
    name: String(raw.name),
    email: String(raw.email),
  }
}

as hides the problem. The mapper solves it.


Conclusion

The TypeScript that helps in Vue 3:

  1. Props with real interfaces and defineProps<T>() — never Object or String in runtime validators
  2. Typed emits with explicit arguments — the component's contract is readable from outside
  3. Refs always typed at declaration — ref<T | null>(null), not ref(null)
  4. Generic composables with <T> — one implementation for multiple data shapes
  5. MaybeRefOrGetter + toValue() — composables that accept refs, getters, and values without forcing the consumer
  6. computed without manual annotation unless the return type isn't obvious
  7. as only at system boundaries, never to silence internal errors

The goal isn't for TypeScript to compile — it's for the editor to tell you what you can do with each value before you try it in the browser.

Questions or want to see a specific case? Write to me at hola@miguel-jimenez.dev.