Skip to content
ExponaBLOG
VUE.JS & NUXT · DAY 024 / 100

TypeScript with Vue: Vue & Nuxt best practices explained

Type-safe components.

Expona Admin7 min read

TypeScript with Vue: Vue & Nuxt best practices explained

By day 24, you've built component hierarchies, managed state, and orchestrated data flow. But without type safety, you're flying blind when passing props down three levels or emitting custom events back up. TypeScript with Vue: Vue & Nuxt best practices explained is where scaling stops being friction and starts being straightforward. This guide walks through the tooling and patterns that senior teams use to keep their Vue and Nuxt codebases predictable, refactorable, and maintainable as they grow.

Why TypeScript matters in Vue

Vue 3 with the Composition API and <script setup> syntax unlocked a TypeScript-native way to write components. Unlike the old Options API, where types felt bolted on, modern Vue actively cooperates with your type checker. You get autocomplete in templates, compile-time errors instead of runtime surprises, and confidence when renaming props or event names across your component tree.

In production codebases—especially e-commerce or SaaS projects handling real money or user data—this matters enormously. A misspelled prop name in a component deep in your tree is caught at build time, not discovered by a customer.

lang="ts" — Enable TypeScript in script setup

The first step is straightforward: declare your script block as TypeScript.

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

const count = ref<number>(0)
const doubled = computed<number>(() => count.value * 2)

const increment = (): void => {
  count.value++
}
</script>

<template>
  <button @click="increment">
    Count: {{ count }} (Doubled: {{ doubled }})
  </button>
</template>

The lang="ts" attribute tells your build tool (Vite, typically) to parse and type-check the script. TypeScript now understands that count is a Ref<number>, so doubled is inferred as a ComputedRef<number>.

In most cases, you can omit explicit type annotations for ref and computed and let TypeScript infer them. But being explicit about return types in computed getters and arrow functions makes your intent clear to teammates reading the code months later.

This becomes especially valuable in Nuxt when you're hooking into lifecycle or server-side rendering logic. A misconfigured composable or a typo in your setup function becomes a type error, not a runtime crash in production.

Typed props and emits — Generic defineProps and defineEmits

Props and emits are your component's public API. Get them wrong, and your entire tree feels brittle. TypeScript's defineProps and defineEmits macros accept generic type arguments, giving you strong contracts.

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

interface UserCardProps {
  user: User
  isSelected?: boolean
}

interface UserCardEmits {
  (e: 'select', userId: number): void
  (e: 'remove', userId: number): void
}

defineProps<UserCardProps>()
defineEmits<UserCardEmits>()
</script>

<template>
  <div class="card">
    <h3>{{ user.name }}</h3>
    <p>{{ user.email }}</p>
    <button @click="$emit('select', user.id)">
      Select
    </button>
    <button @click="$emit('remove', user.id)">
      Remove
    </button>
  </div>
</template>

Now, when a parent component uses <UserCard />, your editor knows exactly which props are required, which are optional, and what shape they should have. If someone passes :user="{ id: 1 }", TypeScript flags the missing name and email fields immediately.

The same applies to emits. When you call $emit('select', userId), TypeScript verifies that 'select' is a valid event name and that userId matches the expected number type. This is invaluable in large codebases where dozens of components emit custom events.

For more complex scenarios—optional props with defaults, validator functions, or union types—you can still use the function form of defineProps, though it's less type-safe:

typescript
<script setup lang="ts">
const props = defineProps({
  status: {
    type: String as const,
    default: 'pending',
    validator: (v: string) => ['pending', 'active', 'done'].includes(v)
  }
})

// TypeScript infers props.status as string, but knows its default
</script>

Prefer the generic form when you can. It keeps your types as the single source of truth.

vue-tsc — Type-check templates in CI

Here's a gotcha: your IDE might show no TypeScript errors, but your CI pipeline fails. Or worse, your team doesn't run type-checking at all, and errors only surface in staging or production.

vue-tsc is a command-line tool that type-checks your entire Vue project, including templates. Install it:

typescript
npm install --save-dev vue-tsc typescript

Add it to your scripts and CI configuration:

typescript
{
  "scripts": {
    "type-check": "vue-tsc --noEmit"
  }
}

Run it locally before pushing, or in your GitHub Actions, GitLab CI, or similar:

typescript
- name: Type-check Vue components
  run: npm run type-check

Now, if you accidentally bind a method that doesn't exist, reference an undefined variable, or pass the wrong type to a prop, vue-tsc catches it. In teams using Nuxt, this also type-checks route parameters, server middleware, and plugin configurations—catching entire classes of bugs before they reach production.

A common workflow in senior teams: type-check runs on every pull request. If it fails, the PR can't merge. This shifts the burden from QA to the developer, where it's cheaper to fix.

Generic components — generic attribute on script setup

Sometimes you want to write a component that works with many types. Think of a reusable list, table, or select dropdown. The new <script setup generic> syntax lets you parameterize your components with TypeScript generics.

typescript
<script setup lang="ts" generic="T extends { id: number; label: string }">
interface ListProps {
  items: T[]
  selectedId?: number
}

interface ListEmits {
  (e: 'select', item: T): void
}

defineProps<ListProps>()
defineEmits<ListEmits>()
</script>

<template>
  <ul>
    <li
      v-for="item in items"
      :key="item.id"
      @click="$emit('select', item)"
      :class="{ active: item.id === selectedId }"
    >
      {{ item.label }}
    </li>
  </ul>
</template>

Now you can use this component with any type that has an id and label:

typescript
<script setup lang="ts">
interface Product {
  id: number
  label: string
  price: number
}

const products: Product[] = [
  { id: 1, label: 'Widget', price: 9.99 },
  { id: 2, label: 'Gadget', price: 19.99 }
]

const handleSelect = (product: Product) => {
  console.log(`Selected: ${product.label}`)
}
</script>

<template>
  <GenericList :items="products" @select="handleSelect" />
</template>

TypeScript knows that when you select a product, the event payload is of type Product, not just any object. This removes entire categories of type-casting bugs and makes refactoring safe—rename a field on Product, and every component that uses GenericList immediately shows errors where that field was accessed.

Common mistakes

Skipping type-check in CI. Developers might have a strict IDE setup locally but assume the build will catch everything. It won't if you don't run vue-tsc before deployment. Make it non-negotiable.

Over-typing primitives. You don't need const name: string = ref<string>('John'). TypeScript infers it perfectly. Over-annotating makes code noisy and harder to maintain when types change.

Mixing generic and function-form defineProps. Pick one style per project and stick with it. Mixing them confuses new team members and makes code reviews harder.

Ignoring generic constraints. If you write a generic component, define its constraints clearly. A constraint like T extends { id: string | number } prevents misuse and documents your intent for future maintainers.

Leaving templates untyped. Even if your scripts are fully typed, templates stay loose unless you configure your editor and use vue-tsc. A Volar extension (in VS Code) paired with vue-tsc in CI gives you full safety.

Key takeaways

  • Use lang="ts" on all script blocks and let TypeScript infer types where possible; be explicit about return types in functions.
  • Define props and emits with generic type arguments (defineProps<T>()) to create a strong public API for each component.
  • Run vue-tsc --noEmit in CI to type-check templates and catch errors before they reach production.
  • Use <script setup generic="T"> for reusable components that need to work with multiple types, keeping your codebase DRY and type-safe.
  • Establish a team convention: all PRs must pass type-checking, all shared components must have typed props and emits, and all CI pipelines must run vue-tsc.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles