Vue

Better Vue Components with TypeScript [12 examples]

The debate between JavaScript and TypeScript has been going on for years, and I thought by 2024 we would have reached a clear conclusion.

Fotis Adamakis
Fotis Adamakis
Senior Software Engineer / Technical Writer
11 min read
September 18, 2024

Better Vue Components with TypeScript [12 examples]

Better Vue Components with TypeScript [12 examples]

The debate between JavaScript and TypeScript has been going on for years, and I thought by 2024 we would have reached a clear conclusion.

Unfortunately, this is not the case.

The discussion is still very much alive, with good arguments on both sides.

Personally, I don’t have a strong opinion, but there’s a quote I find really insightful:

Avoiding TypeScript because you “know JavaScript well” is like refusing to wear a seatbelt because you “know how to drive well”

This analogy shows that TypeScript is about doing everything possible during development to ensure resilience, reliability, and readability. In the end, understanding code is often the hardest part of programming, and this is where TypeScript helps the most. Clear types and some additional enhancements make the code easier to read and understand, especially in larger projects.

Let’s see a few examples that might convince you that Vue components are better with TypeScript.

1. Typing Component Props

Let’s get this out of the way first.

While Vue already supports prop validation, TypeScript takes it a step further by type-checking during compilation time and enabling the use of types and interfaces for complex types.

In JavaScript:

<script setup>
import { defineProps } from "vue";

const props = defineProps({
  foo: { type: String, required: true },
  bar: Number,
});

// props.foo is a string
// props.bar is a number or undefined
</script>

Using TypeScript:

<script setup lang="ts">
const props = defineProps<{
  foo: string;
  bar?: number;
}>();
</script>
<script setup lang="ts">
interface Props {
  foo: string;
  bar?: number;
}

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

2. Typing Component Emits

Typing emits is a big enhancement. Instead of using the event names as strings, we can use TypeScript to prevent typos and ensure the callback function signature is respected.

In JavaScript:

<script setup>
const emit = defineEmits(["change", "update"]);
</script>

Using TypeScript:

<script setup lang="ts">
const emit = defineEmits<{
  change: [id: number];
  update: [value: string];
}>();
</script>

3. Typing Ref and Reactive Data

This is an enhancement we get for free thanks to [type inference](https://www.typescriptlang.org/docs/handbook/type-inference.html).

The following example will only result in an error in TypeScript since it understands that thecount variable should always hold a number.

<script setup>
import { ref } from "vue";

const count = ref(0);
count.value = "string";
</script>

The same rule applies when using a reactive variable

import { reactive } from "vue";

const user = reactive({
  name: "Alice",
  age: 30,
});

user.name = 123; // TypeScript will catch this as an error

4. Typing Server Responses

Typing reactive data shines during API calls when the data should follow a specific contract. Typescript will ensure that all usages of the response across the project match the defined interface.

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

interface User {
  id: number;
  name: string;
  email: string;
}

const userData = ref<User | null>(null);

onMounted(async () => {
  const response = await fetch("https://api.example.com/user");
  const data: User = await response.json();
  userData.value = data; // TypeScript ensures data usages match the User interface
});
</script>

5. Typing Computed Data

Once more, type inference goes a long way without any effort on our part to ensure proper type usage.

import { ref, computed } from 'vue'

const count = ref(0)
// inferred type: ComputedRef<number>
const double = computed(() => count.value \* 2)
// => TS Error: Property 'split' does not exist on type 'number'
const result = double.value.split('')

We can also explicitly define a return type if needed.

const double = computed<number>(() => {
  // type error if this doesn't return a number
});

6. Typing Scoped Slots

Mastering scoped slots is the closest a Vue developer can be to a superpower. A lot of code and complexity can be removed simply by using a scoped slot correctly.

However, with Vanilla Vue managing slot props without type safety can lead to errors and misunderstandings in how slots are meant to be used.

When using TS we can use the [defineSlots](https://vuejs.org/api/sfc-script-setup.html#defineslots) macro to define types for slot props, ensuring that they are used correctly.

<template>
  <slot :msg="message"></slot>
</template>

<script setup lang="ts">
const message = "Hello, Vue!";

const slots = defineSlots<{
  default: (props: { msg: string }) => any;
}>();
</script>

In this example, defineSlots specifies that the component has a default slot that expects a prop named msg of type string. A type mismatch will result in an error.

7. Typing Template Refs

Template refs is the Vue way to have access to a DOM element. Sometimes very useful but almost always problematic when combined with TypeScript. This is why [useTemplateRef](https://vuejs.org/guide/essentials/template-refs.html#accessing-the-refs) was introduced in Vue 3.5 which when combined with TypeScript automatically infers a ref as HTMLInputElement or null.

<script setup lang="ts">
import { useTemplateRef, onMounted } from "vue";

const myInput = useTemplateRef("my-input");

onMounted(() => {
  myInput.value?.focus();
});
</script>

<template>
  <input ref="my-input" />
</template>

8. Typing Provide/Inject

Provide/Inject is a clean way to share data between components avoiding [prop drilling](https://vueschool.io/lessons/prop-drilling). However, without type safety, it’s easy to introduce bugs by injecting the wrong type of data or missing required injects. With TypeScript we can explicitly define the types of provided and injected values, making everything more predictable.

<!-- ParentComponent.vue -->
<script setup>
import { provide } from "vue";

const theme = "dark";
provide("theme", theme);
</script>
<!-- ChildComponent.vue -->
<script setup>
import { inject } from "vue";

const theme = inject("theme"); // No type safety, could inject anything
// theme is assumed to be a string, but TypeScript cannot confirm this
</script>

In the example above injected theme has to be a string but we could inject anything or nothing.

<!-- ParentComponent.vue -->
<script setup lang="ts">
import { provide } from "vue";

const theme = "dark";
provide("theme", theme);
</script>
<!-- ChildComponent.vue -->
<script setup lang="ts">
import { inject } from "vue";

const theme = inject<string>("theme"); // Inject with expected type
// TypeScript ensures that 'theme' is of type string
</script>

Typing the injected variable ensures that theme is of type string.

Of course, an interface can be used as well.

<!-- ChildComponent.vue -->
<script setup lang="ts">
import { inject } from "vue";

interface Theme {
  color: string;
  fontSize: number;
}

const theme = inject<Theme | undefined>("theme");
</script>

Lastly, I have to mention the recommended way of doing dependency injection in Vue, which is using a symbol cast as an InjectionKey, as seen in the [official Vue docs](https://vuejs.org/guide/typescript/composition-api#typing-provide-inject). Personally, I find the overall pattern too verbose with no upside, which is why I often choose not to adopt it. If you see any advantages to it please feel free to leave a comment or reach out to discuss. I’d love to hear your perspective.

import { provide, inject } from "vue";
import type { InjectionKey } from "vue";

const key = Symbol() as InjectionKey<string>;

provide(key, "foo"); // providing non-string value will result in error

const foo = inject(key); // type of foo: string | undefined

9. Generics

TypeScript’s [generics](https://vuejs.org/api/sfc-script-setup.html#generics) can provide a lot of flexibility when combined with a Vue component enabling them to work with multiple types. We can declare generic type parameters using the generic attribute on the script tag.

<script setup lang="ts" generic="T">
defineProps<{
  items: T[]; // Array of items of type T
  selected: T; // Single selected item of type T
}>();
</script>

In this example, the state is typed using the generic parameter T. This means the component can work with any type, as long as it is specified when using the component.

Imagine a list view that uses the same UI to showcase different types of data.

<script setup lang="ts" generic="T">
defineProps<{
  items: T[]; // Array of items of type T
  selected: T; // Single selected item of type T
}>();
</script>

<template>
  <div>
    <h3>Selected Item: {{ selected }}</h3>
    <ul>
      <li v-for="(item, index) in items" :key="index">{{ item }}</li>
    </ul>
  </div>
</template>

<styles>
// ...omitted
</styles>

Now, we can use this component with different types of data, like strings, numbers, or custom objects.

<template>
  <div>
    <!-- List of strings -->
    <ListComponent :items="['Apple', 'Banana', 'Cherry']" selected="Banana" />

    <!-- List of numbers -->
    <ListComponent :items="[1, 2, 3, 4]" :selected="2" />

    <!-- List of custom objects -->
    <ListComponent :items="users" :selected="selectedUser" />
  </div>
</template>

<script setup lang="ts">
import ListComponent from "./ListComponent.vue";

interface User {
  id: number;
  name: string;
}

const users: User[] = [
  { id: 1, name: "John" },
  { id: 2, name: "Jane" },
  { id: 3, name: "Doe" },
];

const selectedUser: User = users[0];
</script>

10. Typed Composables

In large codebases, complex business logic is often abstracted away from components and placed into composables. Composables being reusable stateful functions greatly benefit from strong type safety ensuring that their inputs and outputs are consistent.

In JavaScript it’s easy to misuse them or pass incorrect arguments, leading to potential runtime errors.

// useUser.js
import { ref } from "vue";

export function useUser() {
  const user = ref(null);

  function fetchUser(id) {
    // Fetching user logic
    user.value = { id, name: "John Doe", age: 30 };
  }

  return { user, fetchUser };
}

Using TypeScript, we can define clear types for the arguments and return values of composables, ensuring that they are used correctly.

// useUser.ts
import { ref } from "vue";

interface User {
  id: number;
  name: string;
  age: number;
}

export function useUser() {
  const user = ref<User | null>(null);

  function fetchUser(id: number) {
    // Fetching user logic
    user.value = { id, name: "John Doe", age: 30 };
  }

  return { user, fetchUser };
}

Let me also share another real-life example of form validation in a TypeScript composable.

// useFormValidation.ts
import { ref } from "vue";

interface ValidationResult {
  isValid: boolean;
  errors: string[];
}

export function useFormValidation() {
  const validationResult = ref<ValidationResult>({ isValid: true, errors: [] });

  function validateField(fieldName: string, value: string) {
    // Validation logic
    if (value.trim() === "") {
      validationResult.value = {
        isValid: false,
        errors: [`${fieldName} cannot be empty`],
      };
    } else {
      validationResult.value = { isValid: true, errors: [] };
    }
  }

  return { validationResult, validateField };
}

Consumer:

<script setup lang="ts">
import { useFormValidation } from "./useFormValidation";

const { validationResult, validateField } = useFormValidation();

validateField("username", "");

console.log(validationResult.value.isValid);
</script>

TypeScript ensures correct usage of the composable and type-safe access to validation results.

11. State Management 🍍

One of the biggest pain points in Vue 2 was that the official state management library (Vuex) didn’t play well with TypeScript. This often led to awkward “type gymnastics” and limited type safety, making state management problematic.

This is why [Pinia](https://pinia.vuejs.org/) is now widely adopted. Built on top of composables, it leverages everything we’ve discussed in the previous section providing a much better integration with TypeScript.

Once more type inference goes a long way for simple state variables (Strings, Numbers, Booleans). For complex variables, a type or interface can be used.

import { defineStore } from "pinia";
import { ref } from "vue";
import type { Customer } from "@/types";

export const useCustomerStore = defineStore("customerStore", () => {
  const isRequestLoading = ref(false); // inferred as boolean
  const totalCustomers = ref(0); // inferred as number

  // Complex state variable requiring explicit typing
  const customers = ref<Array<Customer>>([]); // typed as an array of Customer

  return { customers, totalCustomers, isRequestLoading };
});

export default useCustomerStore;

Additionally, actions can greatly benefit from strict typing since they are simple functions with clearly defined inputs and outputs.

On the other hand, getters will typically infer their types directly from the state, making explicit typing redundant in most cases.

import { defineStore } from "pinia";
import { ref, computed } from "vue";
import type { Customer } from "@/types";

export const useCustomerStore = defineStore("customerStore", () => {
  const isRequestLoading = ref(false); // inferred as boolean
  const totalCustomers = ref(0); // inferred as number

  // Complex state variable requiring explicit typing
  const customers = ref<Array<Customer>>([]); // typed as an array of Customer

  // Actions
  function setCustomers(newCustomers: Customer[]): void {
    customers.value = newCustomers;
    totalCustomers.value = newCustomers.length;
  }

  function addCustomer(newCustomer: Customer): void {
    customers.value.push(newCustomer);
    totalCustomers.value++;
  }

  // Getters
  const activeCustomers = computed(() => {
    return customers.value.filter((customer) => customer.isActive);
  });

  const activeCustomersCount = computed(() => {
    return activeCustomers.value.length;
  });

  return {
    // State
    customers,
    totalCustomers,
    isRequestLoading,

    // Actions
    setCustomers,
    addCustomer,

    // Getters
    activeCustomers,
    activeCustomersCount,
  };
});

export default useCustomerStore;

Now the component that uses this store is forced to use it correctly.

Better Vue Components with TypeScript [12 examples]

12. Better IDE Support

Lastly, putting everything together, all these features significantly improve the capabilities of an IDE. With strong typing and type inference, it can provide real-time feedback and offer helpful suggestions, far beyond what a linter alone can achieve. Autocompletion, error highlighting, and type checking improve code quality as you write. The only downside is that switching back to JavaScript feels very primitive in comparison.

Better Vue Components with TypeScript [12 examples]

Conclusion

As demonstrated, using Vue with TypeScript brings a lot of benefits. With features like typed props, emits, slots, state management, and generics, it really improves the readability of a codebase and ensures everything works as expected.

Sure, the learning curve might be a bit steeper compared to JavaScript, but the long-term advantages, like early error detection, cleaner code structure, safer refactoring and better IDE support, make it totally worth it, especially in larger projects.

What is your opinion on using Typescript with Vue? Is it a pleasant experience? Is there a useful feature I missed or a pain point I’m not aware of? Let’s discuss this in the comments below.

Additional resources:

Fotis Adamakis

Fotis Adamakis

Senior Software Engineer / Technical Writer

Experienced software engineer writing about front end architecture, accessibility, system design, and developer productivity. Lessons from building and maintaining large-scale frontend applications, with a focus on practical patterns that make codebases easier to understand, scale, and evolve.

Barcelona, Spain