After working with React (and TypeScript) for a long time, I’ve recently been contributing to a Vue application. While I felt right at home in Vue 3’s Composition API (given how similar it feels to React Hooks), I did miss the ability to easily use TypeScript purely for its props validation… or so I thought.

Options API versus Composition API

I’ve known for quite some time that Vue usually plays nicely with Typescript, but I don’t really have good memories of strongly-typing Vue 2 codebases — in part thanks to the magic of Options API.

For those not familiar with either, the Options and Compositions API are two distinct ways to write Vue components. The former was the previous best practice and the latter is the new preferred way to do it.

They share a lot of similarities but one is more declarative while the other is more functional. Here is a (voluntarily over-engineered) basic example of a counter demonstrated in each.

First up, the Options API:

<template>
    <ul>
        <li>Initial Count: {{ initialValue }}</li>
        <li>Current Count: {{ count }}</li>
        <li>Difference: {{ difference }}</li>
    </ul>
    <button @click="decrement">-</button>
    <button @click="$emit('reset')">Reset</button>
    <button @click="increment">+</button>
</template>

<script>
import { defineComponent } from "vue";

export default defineComponent({
    name: "MyCounter",
    props: {
        initialValue: {
            type: Number,
            default: 0,
        },
    },
    emits: ["reset"],
    data() {
        return {
            count: this.initialValue,
        };
    },
    computed: {
        difference() {
            return this.count - this.initialValue;
        },
    },
    methods: {
        increment() {
            this.count++;
        },
        decrement() {
            this.count--;
        },
    },
});
</script>

I’m not demonstrating the full power of either option here, of course, but this should give you an idea of the main differences. While the template remained unchanged, the script part became much more functional.

The benefits might not seem evident at first, besides that the newer way is shorter. However, it has the added upside of being composable as the name Composition API indicates. This means that, just like hooks, you can slice them any way you want, compose them, and build on top of them.

There are a lot of functions like onMounted and such that will allow you to compose and reuse lifecycle-related logic as more robust, smaller functions. It really rekindled my love of Vue and made it more palatable to someone like me who is more functional-programming oriented.

Strong typing of props

As I mentioned before, both APIs support Typescript without too much effort, but as explained with more detail in the official Vue documentation, strong typing code based on Options API requires quite a lot of type gymnastics and doesn’t always give you an accurate picture due to the ever changing nature of this in class/object-based components. On the other hand, the Composition API has a much easier time with strong typing due to the simpler nature of input/output brought by small isolated functions.

Now with that in mind, I set out to implement strong typing of props in the codebase I was working on. And since the team that would ultimately maintain it wasn’t necessarily proficient with TypeScript, the goal was for my solution to have a minimal footprint.

All I wanted was better type inference for objects and arrays. If you’re not familiar with Vue, with definition props, you define their type by passing a JavaScript prototype:

defineProps({
    user: {
        type: Object,
        required: true,
    },
    comments: {
        type: Array,
        required: true,
    },
    notifications: {
        type: Number,
        default: 0,
    }
});

This is a very simple system that covers a lot of use cases already but you can quickly spot what is problematic here: both Object and Array are way too vague for their own good.

Obviously the former should be some kind of User object and the latter, an array of Comment objects. But if you passed :user="{foo: 'bar'}" :comments="['foo', 'bar']" all hell would break loose and you wouldn’t be doing anything forbidden by your prop types.

So to remedy this, I basically wanted to use more complex (TypeScript) types instead of Object and Array. Turns out this isn’t necessarily hard to do!

Enabling TypeScript support for Vue 3

If you use Vue, chances are you are using Vite for compilation. It is the recommended option when working with the framework. It’s based on Rollup, and it’s very easy to use and pretty fast.

Ultimately this means there isn’t all that much to configure and that’s always nice. If you don’t use Vite, enabling TypeScript is also feasible, of course, but will likely require a bit more wiring and configuration since you’ll probably have to go through Webpack which has a more configuration over convention approach.

The first step is to create a tsconfig.json file, which is always the case when working with TypeScript. Because both Vue and Vite provide some prebuilt things for this, you can get by with just the following:

{
    "extends": "@vue/tsconfig/tsconfig.web.json",
    "compilerOptions": {
        "baseUrl": ".",
        "allowJs": true,
        "types": ["vite/client"],
        "paths": {
            "@/*": ["src/*"]
        }
    },
    "include": ["src/**/*.vue", "src/**/*.ts"]
}

Note that we used allowJs here since again the goal wasn’t to convert the codebase to TypeScript.

Once that is done you are already pretty much good to go… depending on the tooling stack of your app. If you use Storybook, ESLint, Prettier, or other add-ons, you’ll need to make those play nice as well. Most changes are usually one line of config away. For example, ESLint needs the config to extend @vue/eslint-config-typescript and you’re good to go.

Now that Vite is aware that we want to use TypeScript stuff, the second step is to create a something.d.ts file at the root of your code. In this file, you’ll add the types you’ll use. This file can be named anything since TypeScript will pick it up automatically without referencing it explicitly.

Depending on your stack, you have various options in front of you. If, for example, your front-end communicates with an OpenAPI-documented API, you can convert the schemas to TypeScript types which would ensure both stay in sync. Unfortunately, this was not our use case since this was a Laravel codebase with Inertia, which means there was no API nor anything declarative like JSON that could be used to generate types.

So I set out to declare the types myself, which ended up being not that bad. Here’s a very small excerpt for illustration purposes.

CTO Coaching & Mentoring in SaaS: CTO ad interim service - madewithlove
We offer CTO Coaching sessions and Mentoring for technical leaders in the SaaS industry. Our CTO ad interim service will help your startup grow.
interface Paginated<T> {
    data: T[];
    links: any;
    meta: {
        current_page: number;
        from: number;
        last_page: number;
        path: string;
        per_page: number;
        to: number;
        total: number;
    };
}

interface CommonUserAttributes {
    id: string;
    avatar: string;
    photos: UserImageType[];
    location: UserLocation;
}

interface UserType extends CommonUserAttributes {
    description?: UserCharacteristics["description"];
    first_name: string;
    last_name: string;
    email: string;
        ...
}

Once the types were ready, they could then be consumed as prop types. For this you first need to switch the script language to TypeScript:

-<script setup>
+<script setup lang="ts">

Then import the special PropType type from Vue directly:

import type { PropType } from "vue";

Or if there is an existing Vue import, these can also be combined:

-import { computed, watch } from "vue";
+import { computed, type PropType, watch } from "vue";

Finally, you can now add as statements to the props you want strongly typed:

defineProps({
    user: {
-        type: Object,
+        type: Object as PropType<UserType>,
        required: true,
    },
    comments: {
-        type: Array,
+        type: Array as PropType<CommentType[]>,
        required: true,
    },
});

Note that all types are suffixed by Type to avoid name conflicts with components. For example, if we had a Comment component, it could be quite cryptic to debug. For the rest you can pretty much use any and all TypeScript features you’re used to in order to compose your types.

defineProps({
    user: {
        // Use standard TypeScript features like unions
        type: Object as PropType<UserType | PartnerType>,
        required: true,
    },
    comments: {
        // Use generics
        type: Object as PropType<Paginated<CommentType>>,
        required: true,
    },
    labels: {
        // Types do not need to be advanced; you can also strongly type basic things
        type: Array as PropType<string[]>,
        default: () => [],
    },
});

From then on you can safely use your props in your component, and you’ll enjoy strict typing even in the template as long as your editor/IDE has TypeScript support setup, which I highly recommend.

strongly typed props

Build time vs. Live type checking

Now comes the most important part. Because Vite uses Rollup which itself uses Babel, the TypeScript code doesn’t actually ever go through the TypeScript compiler.

Instead, it uses the Babel TypeScript preset which means that, while Babel now knows how to compile your TypeScript code, it doesn’t actually understand it. That’s why in the previous section I stress the importance of having an editor/IDE to accomplish that task, otherwise, even if your TypeScript code is invalid you will never know.

To remedy this, it’s possible to add a plugin to Vite which will check types during compilation. But the downsides are quite major.

First of all, you lose the compilation speed which is one of Vite’s big pros (hell, Vite means Fast in French). But you now also severely hinder your development pace because you’ll have TypeScript barking at you every time you type a letter and your code isn’t immediately perfect and type safe.

It’s for this reason that in full TypeScript codebases I usually drop a // @ts-nocheck at the top of the file I’m working on and remove it at the end to be able to tinker and experiment without having to worry about type safety until the end.

To replicate this behavior with Vite, I configured the plugin to do the type checking at build time, like this:

import { defineConfig } from "vite";
import checker from "vite-plugin-checker";

export default defineConfig({
    // [Rest of the config]
    plugins: [
        // [My other plugins]
        checker({ vueTsc: true })
    ],
});

You may wonder what vue-tsc is. If you’re used to working with TypeScript and Babel, you may be used to manually calling tsc (the TypeScript binary) on your codebase to perform type checking on demand.

This is a common strategy to have the best of both worlds since you enjoy the fast compilation and interoperability of Babel while preserving the ability to call TypeScript when you want to verify that everything is type safe.

The problem is: Typescript has no idea what to do with *.vue files since they’re not really standard. So that’s where vue-tsc comes in. It’s a drop-in replacement for tsc that supports Vue files; that’s it.

So what the Vite plugin does is basically run vue-tsc -p . --noEmit on your codebase to check everything once Vite has completed the build.

Final Words

While we are still rolling this out (and I’ll likely find out more things), I’m already pretty satisfied I was able to implement this and have it work effectively. I caught several errors in our existing components thanks to this and I’m glad I was able to implement it in a way that didn’t require touching 98% of the files in the codebase.

Good luck on your journey to add TypeScript to Vue 3 in this easy way.

Other relevant resources