Why Use TypeScript with Vue.js?

Written by Daniel Kelly

Vue.js is a notoriously flexible framework with applications ranging from progressively enhancing static pages to building full on single page applications. Likewise, Vue also provides the flexibility of developing in either plain ol’ JavaScript or in it’s more strict type-safe counterpart, TypeScript.

That begs the question, should you be using TypeScript in your Vue.js powered application? In this article, let’s examine some of the reasons you might choose to do so.

Catch Errors as You Code

Since TypeScript is a strongly typed language, it is able to detect a much wider range of issues right in your IDE. The alternative to this is finding out about said issues only after the code is run in the browser. This could mean catching the issue during manual testing or only after an end user reports it.

For example, let’s say you wanted to format a 9 digit phone number to have dashes for display purposes.

<script setup>
import { ref, computed } from "vue";
const phone = ref("55566677777");
const [match, one, two, three] = phone.value.match(/^(\d{3})(\d{3})(\d{4})$/);

// 555-666-7777
const formatted = computed(() => `${one}-${two}-${three}`);
</script>
<template>Phone: {{ formatted }}</template>

I’ve intentionally left off lang="ts" thus far to show what would happen with plain JavaScript. Now, the above would work just as expected, but maybe you were in a hurry, or the phone number is actually in a different file, or it’s the result of a function. Long story short, what if the phone number ends up being an actual number instead of a string?

const phone = ref(55566677777);

Working in my IDE, there is no indication of an issue.

This screenshot is directly from VS Code and I can’t tell anything is wrong. There are no red squiggly lines in sight and I’m even using ESLint. We do have a yellow squiggly line under match but that’s only because we aren’t using the variable, it’s not cause for error.

phone number type doesn't cause error in JS

If I hop on over to the browser though, immediately things break down. The whole app stops working and I get this error in the console.

error phone.value.match is not a function

The issue is that numbers don’t have a match method like strings do. Ok, that’s an easy enough fix. Back in the IDE we can wrap the number in quotes again but in doing so we accidentally add another number to it.

const phone = ref("555666777777");

After making the change we go back to the browser and run things again by refreshing the page. This time we get another error.

error phone.value.match is null

This time match is null because the string didn’t match the regex and we can’t spread null into an array.

Even with the small bit of code we’re working with, this process has already gotten a bit annoying but with larger applications it can be more than just annoying. It can lead to repetitive, time consuming testing that takes multiple clicks and waiting in the browser, as well as missing some edge cases all together.

What if I didn’t have to wait to run the code to catch this? That’s where TypeScript comes in. Adding lang="ts" to the component’s script section immediately reveals the error in my IDE and hovering over the error reminds me that the result of match could be an array but it could also be null.

TypeScript hint shows that the result of match could be an array or null

With that information readily available we can now easily handle that edge case by defaulting to an empty array if the match returns null and then displaying the string “Invalid Phone” for our formatted phone number if match is undefined.

<script setup lang="ts">
import { ref, computed } from "vue";
const phone = ref("555666777777");

// default to empty array if result of .match is null
// (making match, one, two, and three undefined)
const [match, one, two, three] =
  phone.value.match(/^(\d{3})(\d{3})(\d{4})$/) || [];

// 555-666-7777
const formatted = computed(() =>
    // check if match is undefined
  match ? `${one}-${two}-${three}` : "Invalid Phone"
);
</script>
<template>Phone: {{ formatted }}</template>

Furthermore, if we accidentally use a real number instead of a string again, TypeScript would alert us of that immediately as well.

TypeScript shows hint that number doesn't have a match method

Refactor with Confidence

Another benefit or working with TypeScript for your Vue.js projects, is that you can refactor with more confidence.

Let’s say you had a PostForm component for a blog application that looked something like this in plain JavaScript.

// PostForm.vue
<script setup>
import { ref } from "vue";
defineEmits(["create"]);
const title = ref("");
const body = ref("");
</script>
<template>
  <form @submit.prevent="$emit('create', { body, title })">
    <input v-model="title" />
    <textarea v-model="body"></textarea>
    <button>Create Post</button>
  </form>
</template>

It keeps track of a title and a body as reactive references. It also emits a create event on form submit with an object that includes the body and title as properties.

Now let’s say you want to refactor that emit into it’s own handler function so that you can do more along with it (like do some validations, reset the form data after submit, or whatever).

// PostForm.vue
<script setup>
import { ref } from "vue";
defineEmits(["create"]);
const title = ref("");
const body = ref("");
const handleSubmit = () => {
  $emit("create", { body, title });
};
</script>
<template>
  <form @submit.prevent="handleSubmit">
    <input v-model="title" />
    <textarea v-model="body"></textarea>
    <button>Create Post</button>
  </form>
</template>

With ESLint enabled in my project, I do at least get notified here that $emit is not defined.

ESLint gives hint that $emit is undefined

To fix that we should capture the emit function returned from the defineEmits macro and then replace the emit inside of the handler function with it.

const emit = defineEmits(["create"]);
//...
const handleSubmit = () => {
  emit("create", { body, title });
};

With that, as far as my IDE knows, everything is good to go. However there is actually still one more issue hiding in the refactor. Can you spot it?

<script setup>
import { ref } from "vue";
const emit = defineEmits(["create"]);
const title = ref("");
const body = ref("");
const handleSubmit = () => {
  emit("create", { body, title });
};
</script>
<template>
  <form @submit.prevent="handleSubmit">
    <input v-model="title" />
    <textarea v-model="body"></textarea>
    <button>Create Post</button>
  </form>
</template>
Learn Vue.js 3 With Vue School

Don’t worry, if you didn’t. It’s tricky! The issue is that previously we were emitting the strings for the body and title. Now though, we are emitting the reactive refs since body and title are no longer being auto unwrapped.

Therefore, in order to keep our component interface the same as before the refactor, we should change the emit to include body.value and title.value.

emit("create", { body: body.value, title: title.value });

Whew! That was a close one. It’s definitely understandable how you might miss that in your own refactoring. The result unfortunately, would probably end up being minutes or more of wasted time trying to figure out what went wrong after testing things out in the browser.

Now let’s examine how the same refactor would probably play out in TypeScript.

First of all, if you were using TypeScript you’d be in the habit of typing your custom events. Thus, you’d already have defined the type of the create events payload something like this.

defineEmits<{
  (e: "create", payload: { body: string; title: string }): void;
}>();

For the TypeScript novice, this just defines the type of the body and title properties in the payload object as strings.

Then after you handled the $emit to emit issue as before you’d end up with the following code.

<script setup lang="ts">
import { ref } from "vue";
const emit = defineEmits<{
  (e: "create", payload: { body: string; title: string }): void;
}>();
const title = ref("");
const body = ref("");
const handleSubmit = () => {
  emit("create", { body, title });
};
</script>
<template>
  <form @submit="handleSubmit">
    <input v-model="title" />
    <textarea v-model="body"></textarea>
    <button>Create Post</button>
  </form>
</template>

This time, though, your IDE will let you know that something smells fishy. VS Code very directly calls out the body and title properties of the event payload with red squiggly lines. Hovering over each of them, shows the issue precisely: that is, a reactive ref is not the same thing as a string.

TypeScript hint shows the Vue custom event expects an object with a title and body property set to strings and not reactive references

The solution, of course, is the same as before but this time we’re able to fix it immediately as the feedback was instant and in the context of the IDE. With the fix in place, the red squiggly lines disappear.

errors in IDE disappear when strings are properly provided instead of the reactive refs

Enhanced IDE Functionality for Communicating Between Components

Besides the enhanced error visibility within your IDE when refactoring, TypeScript will also help you out when communicating between components. This is possible due to more focused and accurate auto-completion options and error detection for props and events.

Let’s take the same example from above. As a reminder, in JavaScript we’d write the emits definition like this.

// PostForm.vue
const emit = defineEmits(["create"]);

If you were to go and actually use the PostForm component in another component now and listen to the create event, you would get no autocomplete options for the event payload.

<PostForm @create="$event"/>

IDE shows no autocomplete options for the create custom event

However, if you switch it back to the type declaration syntax for defining emits like this:

const emit = defineEmits<{
  (e: "create", payload: { body: string; title: string }): void;
}>();

Then you would get a complete and extremely focused auto-complete list of the available properties on the event along with their types.

TypeScript provides autocomplete options for Vue.js custom events

Just imagine how much easier this makes communicating between components! There’s no need whatsoever to go and checkout the code or docs for the component you’re consuming to see the payload of a particular event.

The same benefit applies to props as well. If we were to allow our PostForm to accept an existing post to edit that would look like this.

//PostForm.vue
const props = defineProps<{
    // the question mark makes it an optional prop 
    // (since our form could be used for new or existing posts)
  post?: { body: string; title: string };
}>();

//...

const title = ref(props.post?.title || "");
const body = ref(props.post?.body || "");

At this point, we could even normalize the shape of our post object into a reusable interface.

// PostForm.vue
interface Post {
  body: string;
  title: string;
}
const props = defineProps<{
  post?: Post;
}>();
const emit = defineEmits<{
  (e: "create", payload: Post): void;
}>();

Finally, we should see what actually passing the prop into the component looks like.

// App.vue
<script setup lang="ts">
import { ref } from "vue";
import PostForm from "@/components/PostForm.vue";
const existingPost = ref({
  body: "",
  tite: "",
});
</script>
<template>
  <PostForm :post="existingPost" />
</template>

If you were paying close attention you might notice that I’ve got a typo in my existing post. If you were working in VS code directly though, you wouldn’t have to be paying close attention at all, because TypeScript would clearly call you out.

TypeScript hint shows the Vue prop doesn't contain the correct properties

This is helpful not only for catching typos but, especially for larger objects, ensuring all necessary properties actually exist and are of the right type. In other words, you get documentation right in your IDE of exactly what a prop should look like.

Conclusion

For a JavaScript developer, TypeScript can be fairly intimidating. (I can say that because that was the case for me 😀.) However, jumping over the initial fear and learning even just the most basic syntaxes can help you start reaping the benefits of TypeScript in your Vue.js projects.

If you want to dive deeper into how to use TypeScript with your Vue.js projects, you should checkout our courses: TypeScript Fundamentals and TypeScript with Vue.js 3.

If you’ve never touched TypeScript or just need a refresher on the fundamentals, TypeScript Fundamentals will teach you the basic TypeScript principles and syntaxes that you’ll use on a regular basis.

TypeScript with Vue.js 3 is for those who already know the basics and teaches you the common use cases and ways you’ll use TypeScript in a typical Vue.js 3 project.

No matter where you’re at with TypeScript, if you can relate to the issues described in this article that TypeScript can help solve, then checkout our courses and as always we’re here to help! 😃

Learn Vue.js 3 With Vue School

Leave a Reply

Your email address will not be published. Required fields are marked *

Up Next:

Composing Layouts with Vue Router

Composing Layouts with Vue Router