Vue 3 with TypeScript in 2026: Type-Safe Props, Emits and Composables

Master type-safe Vue 3 components with TypeScript: generic defineProps, tuple defineEmits, typed composables, defineModel and InjectionKey, plus interview questions.

Vue 3 TypeScript type-safe props, emits and composables

Vue 3 with TypeScript in 2026 gives components fully static type checking across props, events, and reusable logic. The <script setup> syntax combined with compiler macros like defineProps and defineEmits turns what used to be runtime-only object definitions into compile-time contracts. This guide covers the type-safe patterns every Vue developer needs for production code and technical interviews.

Type-safe props in one line

In Vue 3.5+, generic defineProps<T>() infers prop types directly from a TypeScript interface — no runtime type fields, no PropType casting. Combined with reactive props destructure, defaults live inline: const { size = 'md' } = defineProps<Props>().

Typing props with defineProps and TypeScript interfaces

The runtime prop declaration (props: { title: String }) works, but it duplicates type information and loses precision. Union types, nested objects, and function signatures cannot be expressed cleanly. The generic form of defineProps solves this by taking the shape from a TypeScript type, which the Vue compiler then converts into the correct runtime declaration automatically.

vue
<!-- UserCard.vue -->
<script setup lang="ts">
interface Props {
  userId: number            // required primitive
  name: string
  role: 'admin' | 'member'  // union type, impossible with runtime props
  tags?: string[]           // optional array
  onSelect?: (id: number) => void  // typed callback prop
}

const props = defineProps<Props>()
</script>

<template>
  <article @click="props.onSelect?.(props.userId)">
    <h3>{{ props.name }}</h3>
    <span>{{ props.role }}</span>
  </article>
</template>

The role prop is now constrained to two literal strings, so passing role="guest" fails the build. This is the core reason to reach for defineProps with TypeScript: the official Vue TypeScript guide treats the generic form as the default for <script setup> projects.

Default prop values: withDefaults vs reactive destructure

Type-only props have no runtime default mechanism on their own. Historically the answer was withDefaults, which wraps the macro and merges a defaults object:

vue
<!-- Button.vue -->
<script setup lang="ts">
interface Props {
  variant?: 'primary' | 'ghost'
  size?: 'sm' | 'md' | 'lg'
  disabled?: boolean
}

// withDefaults keeps reactivity and applies fallback values
const props = withDefaults(defineProps<Props>(), {
  variant: 'primary',
  size: 'md',
  disabled: false,
})
</script>

Vue 3.5 stabilised reactive props destructure, which is now the more concise pattern. Destructuring the defineProps result and assigning a default in the same statement stays fully reactive — the compiler rewrites each access back to props.x under the hood.

vue
<!-- Badge.vue -->
<script setup lang="ts">
interface Props {
  label: string
  color?: 'green' | 'red'
  outlined?: boolean
}

// Defaults are declared inline; each variable stays reactive
const { label, color = 'green', outlined = false } = defineProps<Props>()
</script>

<template>
  <span :class="[color, { outlined }]">{{ label }}</span>
</template>
Reactivity trap with destructured props

Destructured props stay reactive in the template and in computed, but passing a destructured value straight into watch or a composable reads it once and drops the reactive link. Wrap it in a getter — watch(() => color, ...) — or convert it with toRef(props, 'color') when a ref is needed downstream.

This is a common interview trap: candidates assume the destructured variable is a plain value, when the compiler has actually rewired every read back to props.color.

Type-safe emits with defineEmits

Events deserve the same rigour as props. The generic defineEmits describes each event name and its payload as a tuple, giving the parent component autocompletion and the child compile-time checks that the right arguments are emitted.

vue
<!-- SearchInput.vue -->
<script setup lang="ts">
// Tuple syntax: event name -> [ ...payload types ]
const emit = defineEmits<{
  search: [query: string]
  clear: []                       // no payload
  select: [id: number, label: string]  // multiple args
}>()

function onSubmit(value: string) {
  emit('search', value)   // ✅ typed
  // emit('search', 42)   // ❌ compile error: number not assignable to string
}
</script>

This tuple form replaced the older call-signature syntax ((e: 'search', q: string): void) because it reads better and supports multiple events without overloads. When an event carries no data, an empty tuple [] documents that explicitly. Pairing typed emits with typed props produces components whose entire public interface is verifiable before runtime — the same discipline covered in the Vue composition API guide.

Typing composables for reusable logic

Composables are plain functions, so they follow ordinary TypeScript rules — but a few conventions keep them ergonomic. Return Ref types explicitly when the inference is non-obvious, and use generics when a composable wraps arbitrary data such as an API response.

useFetch.tstypescript
import { ref, type Ref } from 'vue'

interface UseFetchReturn<T> {
  data: Ref<T | null>
  error: Ref<Error | null>
  loading: Ref<boolean>
}

// Generic <T> flows through to the caller's typed data
export function useFetch<T>(url: string): UseFetchReturn<T> {
  const data = ref<T | null>(null) as Ref<T | null>
  const error = ref<Error | null>(null)
  const loading = ref(true)

  fetch(url)
    .then((r) => r.json())
    .then((json: T) => { data.value = json })
    .catch((e: Error) => { error.value = e })
    .finally(() => { loading.value = false })

  return { data, error, loading }
}

At the call site the generic parameter makes data fully typed with zero extra annotation:

UserList.vue (script setup)typescript
interface User { id: number; name: string }

// data is Ref<User[] | null> — inferred from the generic
const { data: users, loading } = useFetch<User[]>('/api/users')

Explicitly annotating the return object (UseFetchReturn<T>) is worth the few extra lines: it documents the contract, prevents accidental leaks of internal refs, and gives consumers a single type to import. For deeper composable patterns like argument overloads and lifecycle-aware cleanup, see the advanced Vue composables guide.

Ready to ace your Vue.js / Nuxt.js interviews?

Practice with our interactive simulators, flashcards, and technical tests.

Typing defineModel for two-way binding

defineModel, stable since Vue 3.4, collapses the old modelValue prop plus update:modelValue emit into a single writable ref. Its generic parameter types both directions of the binding at once.

vue
<!-- CurrencyInput.vue -->
<script setup lang="ts">
// model is Ref<number>; parent v-model is type-checked as number
const model = defineModel<number>({ required: true })

function increment() {
  model.value++   // writing back updates the parent
}
</script>

<template>
  <input :value="model" type="number" @input="model = Number(($event.target as HTMLInputElement).value)" />
  <button @click="increment">+1</button>
</template>

Named models — defineModel<string>('title') — map to v-model:title and receive the same typing. This eliminates an entire class of mismatched-payload bugs that the manual prop-and-emit pattern used to hide.

Typing template refs and component instances

Accessing a DOM node or a child component through a ref is where untyped Vue code most often falls back to any. The fix is to parameterise useTemplateRef (Vue 3.5+) or the ref itself with the element type, so property access is checked against the real DOM interface.

vue
<!-- FocusField.vue -->
<script setup lang="ts">
import { useTemplateRef, onMounted } from 'vue'

// The ref is typed as HTMLInputElement | null
const inputRef = useTemplateRef<HTMLInputElement>('field')

onMounted(() => {
  // .focus() and .select() are known because the element type is explicit
  inputRef.value?.focus()
})
</script>

<template>
  <input ref="field" placeholder="Type to search" />
</template>

For a reference to a child component, InstanceType<typeof Child> extracts the component's public type, exposing whatever the child declared through defineExpose. This keeps parent-to-child method calls fully checked instead of guessed.

vue
<!-- Parent.vue -->
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import Modal from './Modal.vue'

// Ref is typed to Modal's exposed API (e.g. open / close methods)
const modal = useTemplateRef<InstanceType<typeof Modal>>('modal')

function launch() {
  modal.value?.open()   // ✅ checked against Modal's defineExpose
}
</script>

Type-safe provide and inject with InjectionKey

Dependency injection across the component tree loses type information unless the key carries it. InjectionKey<T> is a typed symbol that binds a value's type to its key, so provide and inject stay in sync without manual casting.

theme-key.tstypescript
import type { InjectionKey, Ref } from 'vue'

export interface ThemeContext {
  mode: Ref<'light' | 'dark'>
  toggle: () => void
}

// The key permanently associates the ThemeContext type with this symbol
export const ThemeKey: InjectionKey<ThemeContext> = Symbol('theme')
typescript
// provider (script setup) — value must match ThemeContext or it fails to compile
provide(ThemeKey, { mode, toggle })

// consumer — theme is inferred as ThemeContext | undefined
const theme = inject(ThemeKey)
theme?.toggle()

Providing a value whose shape does not match ThemeContext is a compile error at the injection site, catching drift between distant components before it ships.

Common Vue TypeScript interview questions

Interviewers probe whether a candidate understands the boundary between compile-time types and runtime behaviour. A few recurring questions and their sharp answers:

  • Why prefer defineProps<T>() over the runtime object form? Generic props express union types, function signatures, and nested shapes that runtime declarations cannot, and they remove duplicated type/runtime definitions.
  • Are destructured props reactive? Yes in Vue 3.5+, because the compiler rewrites access to props.x. But destructured values passed into watch or a composable are read once — use a getter or toRef.
  • How do you type an emitted event with no payload? An empty tuple: defineEmits<{ close: [] }>().
  • What does defineModel replace? The modelValue prop and update:modelValue emit pair, unified into one typed writable ref.

Practising these against a real question bank sharpens the reflexes interviews reward — the Vue composables interview module drills exactly these patterns. Tooling matters too: run vue-tsc in CI so type errors block merges, and lean on the TypeScript handbook when composable generics get involved. The editor experience is powered by Vue's official Volar tooling, which reads these macros to surface errors inline.

Conclusion

  • Use generic defineProps<Props>() to express union types, optional fields, and callback props that runtime declarations cannot capture.
  • Prefer reactive props destructure with inline defaults in Vue 3.5+, and reach for withDefaults only when a shared defaults object is clearer.
  • Type events with the tuple form of defineEmits so payloads are checked at compile time in both the child and the parent.
  • Annotate composable return types explicitly and use generics to forward caller-supplied data types end to end.
  • Adopt defineModel<T>() for two-way binding to collapse the prop-plus-emit boilerplate into one typed ref.
  • Run vue-tsc in continuous integration so type regressions fail the build rather than reaching production.

Start practicing!

Test your knowledge with our interview simulators and technical tests.

Tags

#Vue 3
#TypeScript
#defineProps
#Composables
#Frontend

Share

Related articles