Autopergamene

Typescript, Vue 3, and strongly typed props

Published 3 years ago
8mn to read
Typescript, Vue 3, and strongly typed props

I’ve recently worked on a Vue application after working for a long time with React, and more particularly with React and Typescript. 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 props validation… or so I thought.

Options API versus Composition API

Now I’ve known for quite some time that Vue usually plays nice with Typescript, but I don’t really have good memories of strongly-typing Vue 2 codebases – in part due to the Options API which relied a lot on magic.

For those not familiar with either, the Options and Compositions API are two distinct ways to write Vue components. The former being the previous best practice, and the latter being 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 written in the two:

<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>

And now with Vue 3 and the Composition 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 setup>
import { computed, ref } from "vue";

defineEmits(["reset"]);
const props = defineProps({
    initialValue: {
        type: Number,
        default: 0,
    },
});

const count = ref(props.initialValue);
const difference = computed(() => count.value - props.initialValue);

const increment = () => count.value++;
const decrement = () => count.value--;
</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 got much more functional.

The benefits might not seem evident at first besides that the newer way is shorter. But it has the added upside of being composable as the name Composition API indicates. Which means that just like hooks, you can slice them any way you want, compose them, build on top of them, etc. There are a lot of functions like onMounted and such that will allow you to compose and reuse lifecycle-related logic into 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

Now as I mentioned before, both APIs support Typescript without too much effort, but as explained more in depth in the official documentation, strong typing Options API code 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. While the Composition API has a much easier time with it 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 into 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 it to have a minimal footprint. All I wanted, was better type inference for objects and arrays. If you’re not familiar with Vue, when 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 first should be some kind of User object and the second 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 wanted to basically be able to use more complex (Typescript) types instead of Object and Array. Turns out this isn’t necessarily hard to do!

Enabling Typescript support

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 is pretty fast. Which means ultimately there isn’t all that much to configure and that’s always nice. If you don’t use Vite this is also feasible 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 of course 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, etc. you’ll of course need to make those play nice as well but most are usually one line of config away. For example for ESLint all needs to be done is to make your config extend @vue/eslint-config-typescript and you’re good to go.

Now that Vite is aware we want to use Typescript stuff, the second step is to create a something.d.ts file at the root of your code, in which you will place the types you’ll use. This file can be named anything since Typescript will pick up on it automatically without referencing it manually. Now again 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.

This was unfortunately 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 not being that bad. Here’s a very small excerpt for illustration purposes.

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;
  // etc.
}

Once the types were ready, they could then be consumed as prop types. For this first you 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 if for example 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 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.

Screenshot 2022-04-01 at 16.05.01.png
Screenshot 2022-04-01 at 16.05.01.png

Build time vs. Live type checking

Now here 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 to 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 also you now severely hinder your development pace because now 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 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 calling tsc (the Typescript binary) on your codebase manually 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 to verify that everything is type safe.

The problem is: Typescript has no idea what to do with *.vue SFC file 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 up once Vite has completed building everything.

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.

© 2025 - Emma Fabre - About

Autopergamene

Typescript, Vue 3, and strongly typed props

Back

Typescript, Vue 3, and strongly typed props

Published 3 years ago
8mn to read
Typescript, Vue 3, and strongly typed props

I’ve recently worked on a Vue application after working for a long time with React, and more particularly with React and Typescript. 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 props validation… or so I thought.

Options API versus Composition API

Now I’ve known for quite some time that Vue usually plays nice with Typescript, but I don’t really have good memories of strongly-typing Vue 2 codebases – in part due to the Options API which relied a lot on magic.

For those not familiar with either, the Options and Compositions API are two distinct ways to write Vue components. The former being the previous best practice, and the latter being 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 written in the two:

<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>

And now with Vue 3 and the Composition 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 setup>
import { computed, ref } from "vue";

defineEmits(["reset"]);
const props = defineProps({
    initialValue: {
        type: Number,
        default: 0,
    },
});

const count = ref(props.initialValue);
const difference = computed(() => count.value - props.initialValue);

const increment = () => count.value++;
const decrement = () => count.value--;
</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 got much more functional.

The benefits might not seem evident at first besides that the newer way is shorter. But it has the added upside of being composable as the name Composition API indicates. Which means that just like hooks, you can slice them any way you want, compose them, build on top of them, etc. There are a lot of functions like onMounted and such that will allow you to compose and reuse lifecycle-related logic into 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

Now as I mentioned before, both APIs support Typescript without too much effort, but as explained more in depth in the official documentation, strong typing Options API code 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. While the Composition API has a much easier time with it 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 into 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 it to have a minimal footprint. All I wanted, was better type inference for objects and arrays. If you’re not familiar with Vue, when 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 first should be some kind of User object and the second 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 wanted to basically be able to use more complex (Typescript) types instead of Object and Array. Turns out this isn’t necessarily hard to do!

Enabling Typescript support

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 is pretty fast. Which means ultimately there isn’t all that much to configure and that’s always nice. If you don’t use Vite this is also feasible 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 of course 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, etc. you’ll of course need to make those play nice as well but most are usually one line of config away. For example for ESLint all needs to be done is to make your config extend @vue/eslint-config-typescript and you’re good to go.

Now that Vite is aware we want to use Typescript stuff, the second step is to create a something.d.ts file at the root of your code, in which you will place the types you’ll use. This file can be named anything since Typescript will pick up on it automatically without referencing it manually. Now again 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.

This was unfortunately 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 not being that bad. Here’s a very small excerpt for illustration purposes.

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;
  // etc.
}

Once the types were ready, they could then be consumed as prop types. For this first you 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 if for example 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 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.

Screenshot 2022-04-01 at 16.05.01.png
Screenshot 2022-04-01 at 16.05.01.png

Build time vs. Live type checking

Now here 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 to 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 also you now severely hinder your development pace because now 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 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 calling tsc (the Typescript binary) on your codebase manually 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 to verify that everything is type safe.

The problem is: Typescript has no idea what to do with *.vue SFC file 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 up once Vite has completed building everything.

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.

© 2025 - Emma Fabre - About