• Fri, Mar 2026

Vue with TypeScript: Best Practices for Scalable Apps

Vue with TypeScript: Best Practices for Scalable Apps

If you’ve worked on medium-to-large Vue projects, you know the pain: props and events grow tangled, shared logic is duplicated, and type-related bugs appear at runtime. TypeScript brings strong typing to the rescue—helping ship more predictable, maintainable, and refactor-friendly code. In this tutorial you'll get real-world guide on using Vue 3 + TypeScript the right way for scalable applications

Why use TypeScript with Vue?

Short version: safer refactors, better editor DX, fewer runtime surprises.

Longer version: TypeScript provides static analysis that uncovers errors before runtime, enforces contracts for component props and events, and helps document intent via types. For teams, this reduces onboarding time and improves maintainability.

In Vue 3, the Composition API and <script setup> pair beautifully with TypeScript—composables become typed building blocks, and components expose clear typed APIs.

Step 1 — Project Setup: Vite + Vue 3 + TypeScript

Use Vite for fast dev experience. Create a new project configured for TypeScript:

// create project with Vite (choose "vue" and "TypeScript" when prompted)
npm create vite@latest my-vue-ts-app -- --template vue-ts

cd my-vue-ts-app
npm install
npm run dev

Key files you’ll see or add:

  • tsconfig.json — TypeScript compiler options.
  • src/shims-vue.d.ts — module declaration for .vue files.
  • vite.config.ts — Vite config with TypeScript support.

Step 2 — Type-safe Components: <script setup lang="ts">

Use the concise <script setup lang="ts"> syntax and type props, emits, refs, computed, and returned values.

Typed Props & Emits

Prefer defineProps and defineEmits with TypeScript types or runtime validators.

<!-- src/components/UserCard.vue -->
<script setup lang="ts">
import { computed } from 'vue'

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

const props = defineProps<{ user: User }>()
const emit = defineEmits<{ (e: 'select', id: string): void }>()

const displayName = computed(() => props.user.name.toUpperCase())

function select() {
  emit('select', props.user.id)
}
</script>

<template>
  <article @click="select">
    <h3>{{ displayName }}</h3>
    <p v-if="props.user.email">{{ props.user.email }}</p>
  </article>
</template>

Why this pattern: The prop interface documents component API, TypeScript ensures callers pass correct data, and emitted events are strongly typed at call sites.

Alternative: Runtime validation + Types

If you need runtime checks (for libraries or public components), combine runtime validators with TypeScript types via PropType:

import { PropType } from 'vue'

defineProps({
  user: {
    type: Object as PropType<User>,
    required: true
  }
})

Step 3 — Typed Composables (the heart of scalable logic)

Composables let you extract logic with strong typing so the rest of the app consumes a stable API.

Example: useApi composable (typed HTTP client)

// src/composables/useApi.ts
import axios, { AxiosInstance } from 'axios'
import { ref } from 'vue'

export function useApi(baseURL = import.meta.env.VITE_API_BASE_URL || '') {
  const client: AxiosInstance = axios.create({ baseURL })

  const loading = ref(false)
  const error = ref<string | null>(null)

  async function get<T>(url: string) {
    loading.value = true
    error.value = null
    try {
      const res = await client.get<T>(url)
      return res.data
    } catch (err: any) {
      error.value = err?.message || 'Request failed'
      throw err
    } finally {
      loading.value = false
    }
  }

  return { get, loading, error, client }
}

Usage keeps types intact:

// Example consuming component
import { useApi } from '@/composables/useApi'
import { onMounted, ref } from 'vue'

interface Todo { id: number; title: string; completed: boolean }

const { get } = useApi()
const todos = ref<Todo[]>([])

onMounted(async () => {
  todos.value = await get<Todo[]>('/todos')
})

Tip: Keep composable return types explicit where beneficial: return { get, loading, error, client } as const or use interface returns to improve inference.

Step 4 — Typed Global State: Pinia (preferred over Vuex for TS)

Pinia is the recommended state library for Vue 3 and works better with TypeScript out of the box than Vuex. It supports typing of stores with auto-inferred state, getters, and actions.

Install Pinia

npm install pinia

Create a typed store

// src/stores/useTodoStore.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export interface Todo {
  id: number
  title: string
  completed: boolean
}

export const useTodoStore = defineStore('todo', () => {
  const todos = ref<Todo[]>([])

  function add(todo: Todo) {
    todos.value.push(todo)
  }

  function remove(id: number) {
    todos.value = todos.value.filter(t => t.id !== id)
  }

  const activeCount = computed(() => todos.value.filter(t => !t.completed).length)

  return { todos, add, remove, activeCount }
})

Register Pinia in main

// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const app = createApp(App)
app.use(createPinia())
app.mount('#app')

Using store in components preserves types via useTodoStore() call—no additional generics required.

Step 5 — Router with TypeScript (typed route params)

Use Vue Router 4 with TypeScript. Define route param types for safer usage.

// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import TodoList from '@/views/TodoList.vue'
import TodoDetails from '@/views/TodoDetails.vue'

const routes = [
  { path: '/', name: 'home', component: TodoList },
  { path: '/todo/:id', name: 'todo-details', component: TodoDetails, props: true }
]

export const router = createRouter({
  history: createWebHistory(),
  routes
})

In components, use typed route params:

// TodoDetails.vue
<script setup lang="ts">
import { useRoute } from 'vue-router'

const route = useRoute()
const id = Number(route.params.id) // guard and parse carefully
</script>

For stronger typing you can create a wrapper or helper function that maps params to specific types and validates them.

Step 6 — API & Models: Use Type-First Contracts

Design types for API responses and use them everywhere. This reduces runtime surprises and clarifies data shapes.

// src/models/todo.ts
export interface TodoDto {
  id: number
  title: string
  completed: boolean
  createdAt?: string
}

Use DTOs in the composable and store to keep consistent types.

// usage
const data = await get<TodoDto[]>('/todos')
store.todos = data.map(d => ({ ...d })) // convert if needed

Step 7 — Testing & Type Safety

Types help, but tests give confidence. Use unit tests with vitest or jest and type-aware test setups.

npm install -D vitest @vue/test-utils @testing-library/vue

Write tests that assert the typed contract where possible.

// example.spec.ts
import { mount } from '@vue/test-utils'
import UserCard from '@/components/UserCard.vue'

it('emits select with id', async () => {
  const wrapper = mount(UserCard, { props: { user: { id: '123', name: 'Jane' } } })
  await wrapper.trigger('click')
  expect(wrapper.emitted()).toHaveProperty('select')
})

Step 8 — Advanced Tips & Patterns

1. Use as const for literal inference

When exporting constant configuration, use as const to preserve literal types.

export const ROLES = ['admin', 'user', 'guest'] as const
export type Role = (typeof ROLES)[number] // 'admin' | 'user' | 'guest'

2. Strongly typed provide/inject

Provide/inject with types reduces runtime errors:

// provider
import { provide } from 'vue'
const key = Symbol('auth')
provide(key, { user: null as null | { id: string }, login: (u: any) => {} })

// consumer
import { inject } from 'vue'
const auth = inject<{ user: null | { id: string }; login: (u: any) => void }>(key)

3. Strict tsconfig for better safety

Enable strict options in tsconfig.json (strict, noImplicitAny, strictNullChecks) to catch issues early. Some third-party libs may need type fixes, but the benefit is large.

4. Module augmentation for global properties

If you add global properties (like app.config.globalProperties.$api), declare them to keep TS happy:

// src/types/shims.d.ts
import { AxiosInstance } from 'axios'
declare module '@vue/runtime-core' {
  interface ComponentCustomProperties {
    $api: AxiosInstance
  }
}

5. Avoid any leak

Use unknown and narrow types explicitly, avoid letting any percolate through your composables.

Full Real-World Example: Typed Todo App (Concise)

The following is a minimal but realistic set of files to show how these pieces glue together. It uses Vite + Vue 3 + TypeScript + Pinia + typed composables.

1) models/todo.ts

// src/models/todo.ts
export interface TodoDto {
  id: number
  title: string
  completed: boolean
  createdAt?: string
}

2) composables/useApi.ts

// src/composables/useApi.ts
import axios from 'axios'
import { ref } from 'vue'

const api = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL })

export function useApi() {
  const loading = ref(false)
  const error = ref<string | null>(null)

  async function get<T>(url: string): Promise<T> {
    loading.value = true
    error.value = null
    try {
      const res = await api.get<T>(url)
      return res.data
    } catch (err: any) {
      error.value = err?.message ?? 'Error'
      throw err
    } finally {
      loading.value = false
    }
  }

  return { get, loading, error, api }
}

3) stores/todoStore.ts (Pinia)

// src/stores/todoStore.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { TodoDto } from '@/models/todo'
import { useApi } from '@/composables/useApi'

export const useTodoStore = defineStore('todo', () => {
  const todos = ref<TodoDto[]>([])
  const { get } = useApi()

  async function fetchTodos() {
    todos.value = await get<TodoDto[]>('/todos')
  }

  function addLocal(todo: TodoDto) {
    todos.value.unshift(todo)
  }

  return { todos, fetchTodos, addLocal }
})

4) views/TodoList.vue

<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useTodoStore } from '@/stores/todoStore'
import type { TodoDto } from '@/models/todo'

const store = useTodoStore()
const newTitle = ref('')

onMounted(() => store.fetchTodos())

function addTodo() {
  const todo: TodoDto = { id: Date.now(), title: newTitle.value, completed: false }
  store.addLocal(todo)
  newTitle.value = ''
}
</script>

<template>
  <section>
    <h1>Todos</h1>
    <input v-model="newTitle" placeholder="New todo" />
    <button @click="addTodo">Add</button>

    <ul>
      <li v-for="t in store.todos" :key="t.id">
        <span>{{ t.title }}</span>
      </li>
    </ul>
  </section>
</template>

This minimal app demonstrates typed composables, typed Pinia store, typed models, and typed components. Expand with typed router, typed unit tests, and more composables as the app grows.

Common Pitfalls & How to Avoid Them

1. Over-typing APIs

Don’t be afraid to type loosely at boundaries (e.g., third-party API responses) and transform them to internal types as early as possible.

2. Mixing Options API and Composition API types

Prefer a single style per component to reduce complexity. If mixing, be explicit about types on both sides.

3. Relying entirely on any

Use unknown then narrow it; avoid any leaking across modules.

Checklist: What to do to make your Vue+TS app scalable

  1. Use <script setup lang="ts"> everywhere for concise components.
  2. Use Pinia for typed global state.
  3. Extract reusable logic into typed composables.
  4. Define models (DTOs) and use them across components & stores.
  5. Enable strict TypeScript settings and fix type issues early.
  6. Write tests using type-aware testing tools.
  7. Document public component APIs with types and JSDoc when needed.

Final thoughts — tradeoffs & adoption

Adopting TypeScript has an upfront cost: more typing work and potential learning curve. But for teams building long-lived, growing apps, the investment pays off in reduced bugs, safer refactors, and better DX. Start small—type new modules, adopt composables, and gradually increase strictness. Over time you’ll gain confidence and a robust codebase ready for scale.

This website uses cookies to enhance your browsing experience. By continuing to use this site, you consent to the use of cookies. Please review our Privacy Policy for more information on how we handle your data. Cookie Policy