← Back to DevBytes

Vue.js TypeScript: Strongly Typed Applications

Vue.js TypeScript: Strongly Typed Applications

Vue.js and TypeScript have become one of the most popular pairings in modern frontend development. While Vue's gentle learning curve makes it approachable for beginners, adding TypeScript transforms it into a robust platform for building large, maintainable applications. This tutorial walks you through everything you need to know to build strongly typed Vue applications, from project setup to advanced patterns.

What Is Vue.js with TypeScript?

Vue.js is a progressive JavaScript framework for building user interfaces. TypeScript is a statically typed superset of JavaScript that compiles to plain JavaScript. When combined, TypeScript provides compile-time type checking, intelligent autocompletion, and safer refactoring for Vue components, composables, stores, and APIs.

Since Vue 3 was rewritten in TypeScript, the framework ships with first-class type definitions out of the box. The Composition API, in particular, was designed with type inference in mind, making it the recommended approach for TypeScript projects.

Why It Matters

Setting Up a Typed Vue Project

The easiest way to start is with Vite's Vue + TypeScript template. This scaffolds a project preconfigured with vue-tsc, the official type checker for Vue single-file components.

npm create vite@latest my-typed-app -- --template vue-ts
cd my-typed-app
npm install
npm run dev

The generated tsconfig.json includes the essential compiler options. Make sure strict is enabled to get the full benefit of TypeScript's safety guarantees.

{
  "compilerOptions": {
    "target": "ESNext",
    "useDefineForClassFields": true,
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "jsx": "preserve",
    "sourceMap": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "esModuleInterop": true,
    "lib": ["ESNext", "DOM"],
    "skipLibCheck": true
  },
  "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"],
  "references": [{ "path": "./tsconfig.node.json" }]
}

You also need a shim file so TypeScript understands .vue imports. Vite's template includes src/env.d.ts:

/// <reference types="vite/client" />

declare module '*.vue' {
  import type { DefineComponent } from 'vue'
  const component: DefineComponent<{}, {}, any>
  export default component
}

Typing Component Props with defineProps

With <script setup>, the defineProps macro accepts a generic type argument that fully describes your props. This is the recommended approach because it provides complete type inference without runtime overhead.

<script setup lang="ts">
interface User {
  id: number
  name: string
  email: string
  role: 'admin' | 'editor' | 'viewer'
}

const props = defineProps<{
  user: User
  isLoading?: boolean
  maxItems?: number
}>()

// Destructure with defaults using withDefaults
const { isLoading = false, maxItems = 10 } = withDefaults(
  defineProps<{
    user: User
    isLoading?: boolean
    maxItems?: number
  }>(),
  {
    isLoading: false,
    maxItems: 10
  }
)
</script>

Note that withDefaults must be used at the top level of <script setup> and cannot be combined with destructured props in the same declaration. Pick one pattern and stick with it.

Typing Emits with defineEmits

Emits are typed by providing a type that maps event names to their payload signatures. This gives you autocomplete on event names and type checking on payloads.

<script setup lang="ts">
interface User {
  id: number
  name: string
}

const emit = defineEmits<{
  (e: 'update', user: User): void
  (e: 'delete', id: number): void
  (e: 'cancel'): void
}>()

function handleSave(user: User) {
  emit('update', user)
}
</script>

Typing Reactive State and Refs

Vue's reactivity primitives are fully generic. ref and reactive infer types automatically in most cases, but you can specify them explicitly when needed.

import { ref, reactive, computed } from 'vue'

// Inferred as Ref<number>
const count = ref(0)

// Explicit type for nullable or complex values
const user = ref<User | null>(null)

// reactive infers the shape
const state = reactive({
  items: [] as string[],
  loading: false
})

// Computed values infer their return type
const double = computed(() => count.value * 2)

// Explicit computed type when inference is ambiguous
const filtered = computed<User[]>(() => {
  return users.value.filter(u => u.role === 'admin')
})

Building Typed Composables

Composables are where TypeScript shines. By typing inputs and outputs clearly, you create reusable logic that is impossible to misuse.

import { ref, onMounted, onUnmounted } from 'vue'

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

export function useFetch<T>(url: string) {
  const data = ref<T | null>(null) as Ref<T | null>
  const error = ref<Error | null>(null)
  const loading = ref(true)

  let controller: AbortController | null = null

  async function execute() {
    loading.value = true
    error.value = null
    controller = new AbortController()

    try {
      const response = await fetch(url, { signal: controller.signal })
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`)
      }
      data.value = (await response.json()) as T
    } catch (e) {
      if (e instanceof Error && e.name !== 'AbortError') {
        error.value = e
      }
    } finally {
      loading.value = false
    }
  }

  onMounted(execute)
  onUnmounted(() => controller?.abort())

  return { data, error, loading, refresh: execute }
}

Using this composable in a component gives you full type safety on the returned data:

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

interface Post {
  id: number
  title: string
  body: string
}

const { data: posts, loading, error } = useFetch<Post[]>('/api/posts')
// posts.value is typed as Post[] | null
</script>

Typing Provide and Inject

Provide/inject can lose type safety if you are not careful. Vue provides InjectionKey to create a typed symbol that links the provider and consumer.

import type { InjectionKey, Ref } from 'vue'
import { provide, inject, ref } from 'vue'

export const themeKey: InjectionKey<Ref<string>> = Symbol('theme')

// Provider
export function provideTheme() {
  const theme = ref<string>('light')
  provide(themeKey, theme)
  return theme
}

// Consumer
export function useTheme() {
  const theme = inject(themeKey)
  if (!theme) {
    throw new Error('useTheme must be used within a provider of themeKey')
  }
  return theme
}

Typing Pinia Stores

Pinia is the official state management library for Vue and has excellent TypeScript support. Define your store using the setup syntax for maximum type inference.

import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

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

export const useCartStore = defineStore('cart', () => {
  const items = ref<Product[]>([])
  const total = computed(() =>
    items.value.reduce((sum, p) => sum + p.price, 0)
  )

  function add(product: Product) {
    items.value.push(product)
  }

  function remove(id: number) {
    items.value = items.value.filter(p => p.id !== id)
  }

  function clear() {
    items.value = []
  }

  return { items, total, add, remove, clear }
})

Typing Template Refs

Template refs to DOM elements or child components can be typed explicitly to access their properties safely.

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import ChildComponent from './ChildComponent.vue'

const inputEl = ref<HTMLInputElement>()
const childRef = ref<InstanceType<typeof ChildComponent>>()

onMounted(() => {
  inputEl.value?.focus()
  childRef.value?.someExposedMethod()
})
</script>

<template>
  <input ref="inputEl" type="text" />
  <ChildComponent ref="childRef" />
</template>

Typing Route Components with Vue Router

Vue Router provides typed route params and query objects when you extend the RouteNamedMap interface. For simpler cases, you can cast using the built-in types.

import { useRoute } from 'vue-router'
import type { LocationQuery } from 'vue-router'

const route = useRoute()

// Access params with type assertion when needed
const userId = Number(route.params.id)

// Type query values explicitly
const search = (route.query.q as string | undefined) ?? ''

Best Practices

Example: A Complete Typed Component

Putting it all together, here is a fully typed component that fetches, displays, and manages a list of users.

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

interface User {
  id: number
  name: string
  email: string
  role: 'admin' | 'editor' | 'viewer'
}

const props = withDefaults(defineProps<{
  title?: string
  pageSize?: number
}>(), {
  title: 'User List',
  pageSize: 10
})

const emit = defineEmits<{
  (e: 'select', user: User): void
  (e: 'error', message: string): void
}>()

const users = ref<User[]>([])
const loading = ref(false)
const errorMessage = ref<string | null>(null)

async function loadUsers() {
  loading.value = true
  errorMessage.value = null
  try {
    const res = await fetch('/api/users')
    if (!res.ok) throw new Error(`Failed: ${res.status}`)
    users.value = (await res.json()) as User[]
  } catch (e) {
    errorMessage.value = e instanceof Error ? e.message : 'Unknown error'
    emit('error', errorMessage.value)
  } finally {
    loading.value = false
  }
}

function handleSelect(user: User) {
  emit('select', user)
}

onMounted(loadUsers)
</script>

<template>
  <section>
    <h2>{{ props.title }}</h2>
    <p v-if="loading">Loading...</p>
    <p v-else-if="errorMessage">{{ errorMessage }}</p>
    <ul v-else>
      <li
        v-for="user in users.slice(0, props.pageSize)"
        :key="user.id"
        @click="handleSelect(user)"
      >
        {{ user.name }} ({{ user.role }})
      </li>
    </ul>
  </section>
</template>

Conclusion

Combining Vue.js with TypeScript gives you the best of both worlds: Vue's intuitive reactivity model and TypeScript's compile-time safety net. By using the Composition API, typing your props and emits with generic syntax, building strongly typed composables, and leveraging tools like vue-tsc in your CI pipeline, you can build applications that scale gracefully while remaining pleasant to maintain. Start small by typing new components and gradually migrate existing ones, and your codebase will become more predictable, self-documenting, and resilient to change over time.

— Ad —

Google AdSense will appear here after approval

← Back to all articles