Get 20% off a year of Vue Mastery

Getting Started with TypeScript + Vue.js

Typescript has been around for more than half a decade and has amassed a sizable user-base in the JavaScript community. With the release of Vue 3.0, which provides enhanced TypeScript support, now is a great time to learn about the library.

The advantages of TypeScript include:

  1. More readable code
  2. Better debugging experience
  3. Code editor support for detecting common errors

In this tutorial, we’ll first walk through an introduction to TypeScript, then we’ll get into applying TypeScript on a Vue project.

Learning TypeScript doesn’t have to be a strenuous undertaking. TypeScript is primarily a type-checking tool, built on top of JavaScript. So for JavaScript programmers, this wouldn’t be like learning a completely new language from scratch.

Since type checking is the central concept behind TypeScript, let’s start our journey with that.


Type checking: Dynamic vs Static

Essentially, type checking is the software process of making sure an object passed to a function or assigned to a variable is the right type of object. If the type checker detects that an object has the wrong intended type (or missing methods), the program will scream with errors.

There are two main approaches of type checking, dynamic and static.

Classic JavaScript uses a dynamic-duck-typing system where the type errors are reported during runtime while a missing method is called on an object.

For example:

function output(user){
  console.log( user.getEmail() ) // ERROR
}

foo({}) // passing an empty object

In the above example code, the error will be reported inside the function, even though the error is originated from the function call where an empty object is passed as user. This misalignment between the location of a reported error and the location where the error can be fixed is the primary disadvantage of dynamic type checking.

On the other hand, TypeScript uses static type checking, in which we can define a type by describing its structure. And we can mark (annotate) variables and parameters with the names of types so that type errors can be detected during compile time or even design time (with a modern code editor).

This is one of the few cases in software engineering where “static” is better than “dynamic.” The big deal of static type checking is that it allows us to catch the type error sooner, and it’s also making it more obvious where the source of that error is.

TypeScript is called a superset of JavaScript, which means any valid JavaScript(ES6) code is also valid TypeScript code. This implies that TypeScript’s type-checking features are optional. We can start with plain JavaScript, then add TypeScript features to the code incrementally.

To test the waters, let’s get a taste of static type-checking in regular JavaScript code.


Hands-on type checking

If you’re using VS Code as your code editor, you already have a static type checker at your disposal because VS Code has built-in TypeScript support.

Let’s start with some JavaScript code:

function foo(obj){
  obj.someMethod()
}

foo({})

This code will give us an error, but only during execution (runtime).

For static type checking, we can create a new type and annotate (mark) a function parameter with the new type, via comments:

📃 foo.js

// @ts-check

/**
 * @typedef {object} MyType 
 * @property {() => void} someMethod 
 */

/** @param {MyType} obj */
function foo(obj){
  obj.someMethod()
}

foo({})

In this code, we are using TypeScript’s type checker through the JSDoc syntax. JSDoc is a markup format for annotating JavaScript files through comments. This means that the above code is perfectly valid JavaScript. This is an experiment of “using TypeScript without TypeScript.”

@typedef defines a new type called MyType. @property adds someMethod to the type’s structural requirement. And finally, @param tells the type checker that the foo function will only accept an object that conforms to MyType's structure.

The @ts-check line on the top of the file tells VS Code to use TypeScript’s type checker.

With this code, anything passed to the function without the required structure will raise an error.

Since the empty object doesn’t conform to the MyType structure, we’ll see a red underline indicating the type error. We no longer have to wait for execution to know there’s a type error in the code!

https://firebasestorage.googleapis.com/v0/b/vue-mastery.appspot.com/o/flamelink%2Fmedia%2F1.opt.1602179346328.jpg?alt=media&token=fbd5830c-8bba-42b5-a2c8-375d492b8bd8

To fix the error, we just need to add a function property someMethod to this empty object.

...
foo({ someMethod: function(){} }) // fixed

Then, the red line should disappear. This is essentially what static type checking is about, that is, reporting typing errors during development.

But as you can see, it’s tedious to write type annotations in this JSDoc comment format. So, let’s move on to defining types and annotating variables in actual TypeScript syntax.


TypeScript 101

To write TypeScript code, we have to create a file with the .ts extension. With the .ts extension, VS Code will be able to use its built-in type checker to “spellcheck” typing errors for us. For now, our goal is to write TypeScript code without type-related errors. (We’ll talk about how to run TypeScript code later.)

First and foremost, TypeScript’s typing system is not class-based, although we can still use classes to create new types. Instead, TypeScript’s main focus is on interface, which is a TypeScript keyword used for creating new types by describing the structures of objects. This structure-oriented approach is more natural to JavaScript where objects are commonly created without classes.

We’ll start with an interface definition, which describes the structure of a custom type:

📃 myProgram.ts

interface FullName {
  first: string;
  last: string;
}

Within the type definition, each field needs to have a type, this can be one of the basic types (string, number, boolean, etc.) or custom types. Our FullName type has two string fields.

Now we can create a variable annotated with this FullName type:

📃 myProgram.ts

interface FullName {
  first: string;
  last: string;
}

let myName: FullName; // ADD

With this type annotation, only objects with the required structure can be assigned to this variable:

📃 myProgram.ts

...
let myName: FullName;
myName = { first: "Andy", last: "Li" } // ADD

On the other hand, any object that doesn’t conform to this structure will be met with an error when assigned to this variable.

https://firebasestorage.googleapis.com/v0/b/vue-mastery.appspot.com/o/flamelink%2Fmedia%2F2.opt.1602179346329.jpg?alt=media&token=6a4b6de4-809e-415b-afe3-10c2d4474ba8

When working with TypeScript, you will be seeing a lot of this kind of error, but that’s a good thing. This simple error-guarding technique is what TypeScript is designed to do.

Although there are many other features in TypeScript, they are all built on this core “theme.”


Type Inference

Before we move any further, I want to bring up an important but passive concept called type inference.

In practice, we often declare and assign to a variable on the same line:

const name: string = "andy";

The string type annotation here is redundant because the type checker is capable of inferring the type from the string value. This is type inference.

So this code is technically the same as the above one:

const name = "andy";

The rule of thumb is, annotate the variables only when the type checker couldn’t infer the type you intended, and whenever it helps with the clarity of the code.

Type inference is ubiquitous in TypeScript, it will come up again and again.

So far, we’ve only been seeing how values and objects are getting typed. Let’s move on to functions.


Functions

Since function is a first-class citizen in JavaScript, a TypeScript introduction wouldn’t be very useful without mentioning how to create types for functions.

Here’s how a function is written with TypeScript annotations:

📃 myProgram.ts

const outputToConsole = function(result: number): void {
  console.log("Output: " + result)
}

Here, the parameter is typed with number. And since the function doesn’t return anything, its return type is void. Now if you try to return a string or a number from the function, it will give you an error.

Just like with object variables, we can use interface to create a new function type and annotate the function variable:

📃 myProgram.ts

// NEW
interface Callback {
  (result: number): void
}

const outputToConsole: Callback = function(result: number): void {
  console.log("Output: " + result)
}

Here, we created a function type called Callback, it takes a number value as a parameter and returns nothing (void). Any function that takes a number and returns nothing will be compatible with this type, such as our outputToConsole function.

With the Callback type in place, we can actually remove all the annotations on the parameter and the return value:

📃 myProgram.ts

interface Callback {
  (result: number): void
}

// CHANGED
const outputToConsole: Callback = function(result) {
  console.log("Output: " + result)
}

The parameter type and the return type can now be inferred from the Callback annotation.


Now let’s tie everything together by creating a function that takes a FullName object and a Callback function as parameters:

📃 myProgram.ts

function getNameLength(name: FullName, callback: Callback): void {
  const len = name.first.length + name.last.length
  callback(len)
}

getNameLength(myName, outputToConsole)

This function will pass the length of the name to the callback function, which in turn will display it to the console.

Now that the code is well annotated with types, we can avoid a whole mess of easily avoidable bugs and typos.

Next, let’s see how to run the TypeScript code we have so far:

📃 myProgram.ts

interface FullName {
  first: string;
  last: string;
}

interface Callback {
  (result: number): void
}

let myName: FullName = { first: "Andy", last: "Li" }

const outputToConsole: Callback = function(result) {
  console.log("Output: " + result)
}

function getNameLength(name: FullName, callback: Callback): void {
  const len = name.first.length + name.last.length
  callback(len)
}

getNameLength(myName, outputToConsole)

Running TypeScript

To run our TypeScript code, we need to install the TypeScript compiler.

To do so, we’ll run the following command in the console:

npm install -g typescript

That’s it. TypeScript is now installed globally on our system.

Now we can use the tsc command to compile our TypeScript file:

tsc myProgram.ts

This compiles the ts file to a new js file. If we check the folder where myProgram.ts is saved, we should find a newly generated js file with the same name. This new file is the actual program that we will run.

Since it’s just a normal JavaScript file, we can run it with node:

node myProgram.js

Now you should see the program output. (the length of a name)

As you can see, we compile the ts file, but run the js file. There’s an important implication from this observation. We don’t run TypeScript code; we only compile it. The eventual program will always be in JavaScript.

If you look inside the generated JavaScript file, you won’t see any type definitions or type annotations. It’s just plain old JavaScript. In fact, it’s the same code from the TypeScript file, just with all the type-related stuff erased.

Now it should be clear that TypeScript is only a technology that helps us to write code; it doesn’t come with a runtime engine.

Now that we’ve looked at the basic rules of TypeScript, we’re ready to venture into a Vue project using TypeScript.


TypeScript and Vue.js

Let’s begin with creating a new Vue project in the console (you’ll have to have the Vue CLI installed globally first):

vue create my-ts-app

The Vue CLI will prompt you with a few options (depending on the version of the CLI):

- Default ([Vue 2] babel, eslint) 
- Default (Vue 3 Preview) ([Vue 3] babel, eslint) 
- **Manually select features** 

Choose Manually select features.

Then, it will prompt you with a list of features that you can turn on/off by using the up/down keys and space:

 ◉ Babel
 ◯ **TypeScript**
 ◯ Progressive Web App (PWA) Support
 ◯ Router
 ◯ Vuex
 ◯ CSS Pre-processors
 ◉ Linter / Formatter
 ◯ Unit Testing
 ◯ E2E Testing

Move down to the TypeScript option, hit the space key to turn it on, then hit enter.

Finally, choose Vue 3:

- 2.x 
- **3.x (Preview)**

(There will be more prompts after this but you can just choose the default options.)

After the app is generated, start the dev server:

cd my-ts-app
npm run serve

Here’s the default script code you would see in the App.vue file:

📃 /src/App.vue

import { defineComponent } from 'vue';
import HelloWorld from './components/HelloWorld.vue';

export default defineComponent({
  name: 'App',
  components: {
    HelloWorld
  }
}); 

It’s a little different from a non-TypeScript component because of the extra defineComponent function. This function provides some out-of-the-box type checking for our components.

For example, if we accidentally imported something that isn’t a component, VS Code will warn us:

https://firebasestorage.googleapis.com/v0/b/vue-mastery.appspot.com/o/flamelink%2Fmedia%2F3.opt.1602179353419.jpg?alt=media&token=f88552e9-ca12-4a9d-a391-504bbe2e23f7

Because the components option only accepts objects with a component-like structure. We wouldn’t have this type checking advantage without the defineComponent function.

Now let’s prepare a simple app in plain JavaScript just so that we have something to work on.

Replace the default code inside App.vue with this code:

📃 /src/App.vue

<template>
  <div id="app">
    <h1>My TS App</h1>
    <p><input type="text" v-model="inputText" /></p>
    <p>Count: {{ count }}</p>
    <p><button @click="reset()">Reset</button></p>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue';

export default defineComponent({
  name: 'App',
  data: () => {
    return { inputText: '' }
  },
  methods: {
    reset(){
      this.inputText = ''
    }
  },
  computed: {
    count() {
      return this.inputText.length
    }
  }
});
</script>

We have one state inputText, and the computed property count is based on inputText.

This is a simple app with an input textbox, an output element, and a reset button. When the user starts typing in the textbox, they will see the number of characters displayed below it. The reset button can be used to reset the textbox content.

https://firebasestorage.googleapis.com/v0/b/vue-mastery.appspot.com/o/flamelink%2Fmedia%2F4.opt.1602179356807.jpg?alt=media&token=5534e25c-6190-42bb-a954-e14bdb05b593


Enter TypeScript

Now let’s add some types to the code. Our goal is to make sure that all the data going out from the <script> code to the <template> code are all statically typed so that we can catch any type-related mistakes immediately.

First, we need to create a State type with the inputText field, and use that to annotate the return value of the data method:

📃 /src/App.vue

import { defineComponent } from 'vue';

// ADD
interface State {
  inputText: string;
}

export default defineComponent({
  name: 'App',
  data: (): State => { // ADD
    return { inputText: '' }
  },
  methods: {
...

Now if we accidentally change inputText to a non-string value, the type checker will warn us.

https://firebasestorage.googleapis.com/v0/b/vue-mastery.appspot.com/o/flamelink%2Fmedia%2F5.opt.1602179360080.jpg?alt=media&token=a02a1f42-79b5-4d56-bdf4-e41112f0c943

Another outgoing data that we need to annotate is the computed property count. It’s just a number, so we don’t need to create a new type. We just need to annotate the return type of the count method with number:

📃 /src/App.vue

...
  computed: {
    count(): number { // ADD
      return this.inputText.length
    }
  }
});

If we accidentally return this.inputText instead of this.inputText.length (which is a common thing), the code editor will inform us of this error immediately.

https://firebasestorage.googleapis.com/v0/b/vue-mastery.appspot.com/o/flamelink%2Fmedia%2F6.opt.1602179363304.jpg?alt=media&token=7cbd2a02-78e9-4766-820a-2198da0322ad

Now our code is nicely typed.


Props

Let’s create a second component so that we can demonstrate how to set types for props.

Create a new component file by extracting the <p> element that displays the count and the corresponding computed property from App.vue:

📃 /src/components/CharCount.vue

<template>
  <div id="char-count">
    <p>Count: {{ count }}</p>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue';

export default defineComponent({
  name: 'CharCount',
  props: ['inputText'],
  computed: {
    count(): number {
      return this.inputText.length
    }
  }
});
</script>

This new component will receive the inputText as a prop from the App component.

Let’s add a second prop label so that the parent component is able to set the label text.

📃 /src/components/CharCount.vue

export default defineComponent({
  name: 'CharCount',
  props: ['inputText, label'], // CHANGE
  ...

Also, change the template to render label:

📃 /src/components/CharCount.vue

<template>
  <div id="char-count">
    <p>{{ label }}: {{ count }}</p>
  </div>
</template>

To provide types for these two props, we can use Vue’s built-in prop type mechanism (this is not TypeScript related):

📃 /src/components/CharCount.vue

export default defineComponent({
  name: 'CharCount',
  props: {
    inputText: {
      type: String, 
      required: true,
    },
    label: {
      type: String, 
      required: true,
    },
  },
  ...

We typed both props as String and made them required so that we’ll see errors in the browser console if any of them are missing.

The problem with this approach is that Vue’s default prop type system is not compatible with the TypeScript system. For instance, we won’t be able to export a prop’s type to other components so that those other components can conform to the same type. As a result, we would lose static type checking for props passing.


TypeScript for props

Now, let’s see how to use TypeScript to type the props instead of Vue’s default prop type.

First, create a new type CharCountParams for the two props:

📃 /src/components/CharCount.vue

// NEW
interface CharCountParams {
  label: string;
  inputText: string;
}

export default defineComponent({
  name: 'CharCount',
  ...

(Notice that the TypeScript’s string is all lowercase, while Vue’s prop type String is capitalized.)

Now that we have a new type for the props, we need to use it to annotate the props in place of the current Vue prop type annotations.

Import the PropType generic type provided by Vue, and use our CharCountParams with it:

📃 /src/components/CharCount.vue

import { defineComponent, PropType } from 'vue'; // CHANGE

interface CharCountParams {
  label: string;
  inputText: string;
}

export default defineComponent({
  name: 'CharCount',
  props: {

    // CHANGE
    params: {
      type: Object as PropType<CharCountParams>,
      required: true,
    }
  },
  ...

Here we’re still using Vue’s default prop type system (the Object part), but we’re only using it as a middleman. We first set the prop type as Object, but we soon converted it to CharCountParams through PropType. (PropType is a TypeScript type intended for this situation.)

Although we’re setting the params prop type as PropType<CharCountParams>, the actual params object will be typed CharCountParams. This is the magic of generic.

You can think of generic types as “functions” that are used for creating more specific types. In this case, we’re using the PropType generic type and passing our CharCountParams type as a parameter through the angular brackets. This will create a new specific type for us, the PropType<CharCountParams> type that we need for our prop.

Generic is an advanced static type concept, but this is the only place we use generic in this tutorial. It’s fine to not fully understand generic at this point, just remember that you need to wrap your custom type in PropType with the angular brackets whenever you’re dealing with prop types.

Now both label and inputText will be available as properties of the prop params.

Since now we’re using one params prop to encapsulate inputText and label, we need to change the computed property and the template accordingly:

📃 /src/components/CharCount.vue

<template>
  <div id="char-count">
    <p>{{ params.label /* CHANGE */ }}: {{ count }}</p>
  </div>
</template>

...
  computed: {
    count(): number {
      return this.params.inputText.length // CHANGE
    }
  }
});

As I mentioned earlier, the advantage of using TypeScript types over Vue’s prop types is that we can export a TypeScript type so that other components can comply with it.

So, let’s export our CharCountParams type:

📃 /src/components/CharCount.vue

// CHANGE
export interface CharCountParams {
  label: string;
  inputText: string;
}

export interface CharCountParams {
  name: 'CharCount',
...

Here’s the full code of the CharCount component:

📃 /src/components/CharCount.vue

<template>
  <div id="char-count">
    <p>{{ params.label }}: {{ count }}</p>
  </div>
</template>

<script lang="ts">
import { defineComponent, PropType } from 'vue';

export interface CharCountParams {
  label: string;
  inputText: string;
}

export default defineComponent({
  name: 'CharCount',
  props: {
    params: {
      type: Object as PropType<CharCountParams>,
      required: true,
    }
  },
  computed: {
    count(): number {
      return this.params.inputText.length
    }
  }
});
</script>

Now back to the App.vue file, import the new component and the params type:

📃 /src/App.vue

// ADD
import CharCount, { CharCountParams } from './components/CharCount.vue';

export default defineComponent({
  name: 'App',
  components: { CharCount }, // ADD
  data: (): State => {
    return { inputText: '' }
  },
  ...

(Make sure you add the CharCount component to the components option, this will allow us to render CharCount in the template.)

Create a new computed property with the CharCountParams type as the return type, this will be used to build the params prop for the CharCount component:

📃 /src/App.vue

...
methods: {
  reset(){
    this.inputText = ''
  }
},
computed: {
  // ADD
  charCountParams(): CharCountParams {
    return { 
      inputText: this.inputText, 
      label: 'Count', 
    }
  }
}
...

Finally, render CharCount and pass along the computed value:

📃 /src/App.vue

<template>
  <div id="app">
    <h1>My TS App</h1>
    <p><input type="text" v-model="inputText" /></p>
    <CharCount :params="charCountParams"></CharCount> <!-- ADD -->
    <p><button @click="reset()">Reset</button></p>
    ...

We have successfully typed the params prop on both sides. If somehow we’re missing a field in the computed property, TypeScript will let us know of the error right on the code.

https://firebasestorage.googleapis.com/v0/b/vue-mastery.appspot.com/o/flamelink%2Fmedia%2F7.opt.1602179366587.jpg?alt=media&token=27ca629e-5780-4ed2-861c-679227b14683

Our final code for the App component:

📃 /src/App.vue

<template>
  <div id="app">
    <h1>My TS App</h1>
    <p><input type="text" v-model="inputText" /></p>
    <CharCount :params="charCountParams"></CharCount>
    <p><button @click="reset()">Reset</button></p>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue';
import CharCount, { CharCountParams } from './components/CharCount.vue';

interface State {
  inputText: string;
}

export default defineComponent({
  name: 'App',
  components: { CharCount },
  data: (): State => {
    return { inputText: '' }
  },
  methods: {
    reset(){
      this.inputText = ''
    }
  },
  computed: {
    charCountParams(): CharCountParams {
      return {
        inputText: this.inputText,
        label: 'Count',
      }
    }
  }
});
</script>

Where to go from here

Mastering TypeScript is a huge step for a JavaScript programmer, but it’s worth the effort, as it will open up a whole new world of development experience and opportunities.

In terms of applying TypeScript on Vue projects, we have only scratched the surface. There are various other styles of Vue components such as class-based components and the new Composition API components, each one of these styles requires a different method of annotating the code with types. So this TypeScript journey has just begun.

Fortunately, TypeScript is a progressive tool, we can take it one step at a time in its adoption. It could become your new default language before you know it. In fact, we are currently developing a course on Vue 3 + TypeScript, which is soon to be released.

Download the cheatsheets

Our Vue essentials, Vue 3, and Nuxt.Js cheat sheets save you time and energy by giving you essential syntax at your fingertips.