A Troubled Tale of Type-Safety in Vue 3

Vue 3 is great — Evan You, the contributing team, and the community at large have done an amazing job. After spending the last few months with it, my convictions are stronger than ever: Vue 3 is a boon to developer productivity and any migration to it will pay dividends.

However, my own path to type-safe Vue 3 has been fraught with challenges. I have trouble compromising on type-safety — every (this as any) I write brings premonitions of future late-night debugging sessions. With Vue 3 advertising myriad TypeScript improvements across the board, I was caught off-guard when I ran into problems!

This isn’t to say TypeScript support hasn’t improved in Vue 3 — it has. There’s a deeper conflict at play; the nature of Vue that makes it so empowering to work with also makes strong typing challenging in many real-world scenarios. Typing sophisticated components is hard; the benefit is Vue’s wonderfully expressive/composable component code. Typing code embedded in templates is hard; the benefit is straightforward HTML templates and single-file-components. It’s all tradeoffs here — the good news is that you can have the best of both worlds. Vue 3 is emminently flexible, and as it turns out, so is its TypeScript integration.

Solving the Vue+TypeScript puzzle will be the subject of the rest of this post. Just before we get to that, I’d like to highlight a few aspects of Vue 3 that have been the biggest boosters in productivity for me, far outweighing any TypeScript frustrations along the way:

  • Better TypeScript support throughout — specifically, better type inference within components.

  • <teleport> for rendering component-relevant content elsewhere in the app. This makes so many things dead-simple, such as managing site headers content, or <title> and <meta> tags for SEO — will plug vue-meta since it’s a fantastic plugin for doing this sort of thing, but achieving similar results in "vue-native" fashion feels amazing.

  • Modern JS syntax in templates — many now-standard JS features such as ?. optional-chaining and ?? nullish coalescing are now supported. Consistency between host language and templating language is more important to dev productivity than it seems.

  • Composition API — a new, more-composable, hook-esque pattern for structuring components.

I personally enjoy the consistency and structural clarity that comes from Vue 2’s Options API component structure (many in the Vue community seem to agree — in fact, the transition has caused something of an ideological schism among Vue developers). In my experience they mix quite well, and can both be great ways to structure code in different scenarios. Flexibility is always a good thing — you never know what you’ll need to build next. Vue’s maintainers seem strongly committed to maintaining both approaches as "first-class citizens" of Vue architecture, so I have to applaud them for taking on the challenge of component logic composition with something entirely new, while leaving the system we know and love intact.

Despite any lasting love I may have for Options, Composition comes with a side-benefit that has become a linchpin of my Vue+TypeScript strategy. This "feature" allows us to strong-type Composition or Options -structured components, without sacrificing type safety anywhere due to tempting as any typecast shortcuts. Read on, and we’ll sort out how to convince Vue 3 and TypeScript to set aside their differences, and go to work for you.

The Trouble with Typles (in Vue)

Vue 3 improves TypeScript support across the board (the full rewrite of the framework in TypeScript has definitely helped here). So you may be wondering — What’s the problem?

Vue 3 introduces the Composition API as its new preferred pattern for structuring components, preferred due to advantages in flexibility and code-reuse over the classic Options API component structure. Mixins are now deprecated; class-based component structuring seems to have fallen out of favor with the core group; all hail composition. If you’ve been hitherto unaware of this evolution of Vue, definitely peruse the fantastic doc they’ve put together on the subject: https://v3.vuejs.org/guide/composition-api-introduction.html

Vue’s TypeScript integration works great as long as you’re sticking to the patterns laid out by the Composition API. However, there are many real-world scenarios and motivations you may come across, where perfect adherence to the latest-and-greatest patterns just doesn’t work that well:

  • You’re migrating an existing codebase, and want to move towards Composition API patterns incrementally, but you still want to migrate you existing components to Vue 3 to benefit from its other advances.

  • You perhaps don’t love the Composition API component structure — many don’t; in fact it’s caused a bit of a schism in the Vue community. The Options API structure is still fully supported, and it’s a great way of building components! (it’s worked fantastically for years and gotten Vue where it is today)

  • You have domain-specific use-cases where Composition API constructs just don’t work as well as more standard class-instance constructs, such as in the examples coming up next.

In all of these situations, you shouldn’t have to sacrifice type safety that, when fully operational, actively protects you and your colleagues from bugs — entire classes of typos, data-mismatch errors, refactoring mistakes — that would burn countless hours of painstaking debugging cycles otherwise. The type system should always work for you.

So, circling back, the aforementioned problem: Vue 2 allowed you to extend its type system to account for this diverse array of use-cases, and Vue 3 — on its face — throws out the silver bullet that made this possible. In the following examples I’ll demonstrate how complete component type safety was achieved in Vue 2, why the same techniques don’t work in Vue 3, and finally, how we can utilize new Vue 3 constructs in novel ways to regain safety and sanity of our component code.

These examples embrace Single-File Components (SFCs in Vue lingo) for brevity, but the same TypeScript techniques will work for any form of Vue/TypeScript code if SFCs aren’t your cup of tea. Without further ado, let’s look at some code! Please consider this component, valid in Vue 2 or Vue 3, written in classic JavaScript:

quill-editor.vue
<template>
  <div class="quill-container" ref="quillContainer"></div>
</template>

<script>
import Quill from "quill";

// `export default` here is standard for Vue single-file components:
export default {
  name: "QuillEditor",
  data() {
    return {
      value: "",
      // `value` should stay in sync with the Quill editor's content
    };
  },
  mounted() {
    this.quill = new Quill(this.$refs.quillContainer);
    this.quill.on("text-change", () => {
      this.value = this.quill.root.innerHTML;
      this.$emit("input", this.value);
      // `$emit` is Vue-speak for `trigger an "input" event`
    });
  },
  watch: {
    value() {
      if (this.value !== this.quill.root.innerHTML) {
        // if `value` changed, update Quill editor content accordingly:
        this.quill.setText(this.value);
      }
    },
  },
};
</script>

This is a very simple Vue wrapper for the wonderful Quill rich-text editor (I’m taking a shortcut around this.quill.setText, which is safer than the XSS-prone and aptly-named dangerouslyPasteHTML; terse examples are hard so please forgive me). Our editor wrapper component functions as follows:

  • It defines a container element for Quill in its <template>

  • On mounted() (the moment right after the component is added to the DOM), it sets up a new instance of Quill, and passes the container element so that the Quill editor is rendered there.

  • Importantly, the Quill instance is saved as this.quill on the component. This is where TypeScript would start yelling at us, if we were writing TypeScript.

  • We assign an event handler to trigger an input event whenever the Quill editor content is changed: this.quill.on("text-changed", …​. This allows any parent of our component to react to editor changes, exactly if our component were a standard <input> or <textarea>.

  • We store the Quill editors new value in this.value after any change, and we "watch" that value using Vue’s reactive functionality. This allows parent components to interact with ourComponent.value in exactly the same way as if our component were an <input> or <textarea>. We want console.log(ourComponent.value) and ourComponent.value = "foobar" to work as expected.

What happens if we try to convert this component to TypeScript? This is one place where Vue shines — converting a single-file component to TS is extremely easy, with only two changes required:

  • <script> is changed to <script lang="ts">

  • The return object is wrapped in a Vue component constructor function — this step is what makes type inference of this possible within the component.

In Vue 2:

quill-editor.vue
<template>
  <div class="quill-container" ref="quillContainer"></div>
</template>

<script lang="ts">
import Vue from "vue";
import Quill from "quill";

export default Vue.extend({
  name: "QuillEditor",
  ...
});
</script>

In Vue 3:

quill-editor.vue
<template>
  <div class="quill-container" ref="quillContainer"></div>
</template>

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

export default defineComponent({
  name: "QuillEditor",
  ...
});
</script>

All done? Not quite. In both cases we immediately run into an issue — TypeScript doesn’t like when we make our Quill instance part of our component:

TypeScript Output:
ERROR in src/components/QuillEditor.vue:17:10
TS2339: Property 'quill' does not exist on type 'CreateComponentPublicInstance<{}, {}, { value: StringConstructor; }, {}, {}, ComponentOptionsMixin, ComponentOptionsMixin, EmitsOptions, ... 9 more ..., {}>'.
    15 |   },
    16 |   mounted() {
  > 17 |     this.quill = new Quill(this.$refs.quill);
       |          ^^^^^

In Vue 2 there was an extremely effective (albeit somewhat magical) solution to this problem, that worked for any use-case imaginable. Never mentioned in the docs, anyone who’s searched forums for answers has probably come across the infamous Vue as VueConstructor pattern:

quill-editor.vue
<template>
  <div class="quill-container" ref="quillContainer"></div>
</template>

<script lang="ts">
import Vue, { VueConstructor } from "vue";
import Quill from "quill";

export interface QuillEditor extends Vue {
  quill: Quill;
}

export default (Vue as VueConstructor<QuillEditor>).extend({
  name: "QuillEditor",
  ...
});
</script>

While unwieldy, the pattern works reliably in all cases — writing this.quilll by accident within the component will now result in the error being reported by TypeScript! (immediately, via vue-cli-service serve if you’re into that)

Not only that, but since we export interface parent components of QuillEditor can import type { QuillEditor } and benefit from strong types themselves. For example:

fancy-component.vue
<template>
  <QuillEditor ref="quillEditor"></QuillEditor>
</template>

<script lang="ts">
import Vue, { VueConstructor } from "vue";

import QuillEditor from "./quill-editor.vue"

export interface FancyComponent extends Vue {
  $refs: {
    quillEditor: QuillEditor;
  };
}
;
export default (Vue as VueConstructor<FancyComponent>).extend({
  name: "FancyComponent",
  components: { QuillEditor },
  mounted() {
    console.info(this.$refs.quillEditor.valu);
    // TypeError: `valu` does not exist on type `QuillEditor`
    // reported at compile time. Bug avoided!
  },
});

With a bit of extra boilerplate, the Vue as VueConstructor has given us fully-customizable, strong component types at all layers of our component hierarchy. Anyone who’s managed complex webs of components knows how valuable this can be — instead of a QA/prayer session after that big refactor or new feature, you get peace of mind knowing that as related components are changed, the boundaries between them are guaranteed to be safe. The type system is truly working for you now.

Then you try and migrate your beautifully-typed app to Vue 3, and Vue as VueConstructor is thrown out the window. You search https://v3.vuejs.org/guide/typescript-support.html frantically, but amidst reams of helpful descriptions of TypeScript props and global declaration files, you find no mention of VueConstructor being removed (though you can no longer import it), no word of any valiant type definition come to aid you in its stead.

All is not lost — while not mentioned in the docs (at time of writing), Vue 3 comes with a built-in method of extending any component type, composition-structured or otherwise, through creative use of the Composition API’s new setup method.

Making TypeScript Work For You in Vue 3

The Vue docs, and general writing around the internet, would lead you to believe that the Composition API and Options API approaches to component structure are, for the most part, mutually exclusive. You can throw an Options API method/watch/what-have-you on a component here and there if you want — there’s nothing stopping you — but generally there’s no good reason for mixing the two. Not so with TypeScript in the picture; the setup method of house Composition API will play well with any existing Options API structure, and unlock strong typing of anything we want.

Recall our canonical VueConstructor example that allowed us to extend our component interface, in TypeScript’s eyes:

quill-editor.vue
<template>
  <div class="quill-container" ref="quillContainer"></div>
</template>

<script lang="ts">
import Vue, { VueConstructor } from "vue";
import Quill from "quill";

export interface QuillEditor extends Vue {
  quill: Quill;
}

export default (Vue as VueConstructor<QuillEditor>).extend({
  name: "QuillEditor",
  ...
});
</script>

Now that we understand setup as not just a Composition API denizen, but a purveyor of strong types everywhere, we can drop it into our Vue 3 component and achieve the exact same effect:

quill-editor.vue
<template>
  <div class="quill-container" ref="quillContainer"></div>
</template>

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

export default defineComponent({
  name: "QuillEditor",
  setup() {
    return {
      quill: null,
    } as {
      quill: Quill | null; // type of this.quill is defined here
    };
  },
  ...
});
</script>

By Composition API mechanics, any return value is merged into the component instance and accessible via this.xyz. What they don’t mention is that this applies to they type of the return value as well — it’s merged into the type of this on the final component, so TypeScript becomes savvy to any strong typing you want to stick in there.

In our example component, this.quill written anywhere in the component becomes valid in TypeScript’s eyes, and it will yell if this.quil (or any other mistake) slips in anywhere. Wonderful strong types abound, and bugs are squashed dead in their tracks.

If you have strict or strictNullChecks in your tsconfig.json, your component code will require one further modification. You may have noticed we set this.quill’s type to Quill | null above — this accurately represents the type through the lifecycle of the component since this.quill starts out as null, and only becomes a Quill instance after our component is mounted. It’s good practice to write code acknowledging these "realistic" type conditions if possible; code that doesn’t make assumptions like " this.quill should always be defined, even though they types say otherwise" is less coupled to the particulars of component behavior, and less likely to break as the component evolves.

Luckily, optional chaining operators give us an elegant and terse syntax for writing robust code that accounts for null types — in our component, we can simply write this.quill?.xyz to access it safely. With setup typing and null safety incorporated, our final type-safe Vue 3 component would look like this:

quill-editor.vue
<template>
  <div class="quill-container" ref="quillContainer"></div>
</template>

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

export default defineComponent({
  name: "QuillEditor",
  data() {
    return {
      value: "",
    };
  },
  setup() {
    return {
      quill: null,
    } as {
      quill: Quill | null;
      // type of `this.quill is defined here
      // `null` must also be a valid type for it, since
      // `this.quill` is initialized as `null`
      $refs: {
        quillContainer: HTMLElement;
      },
      // `this.$refs` may be strong-typed here too!
    };
  },
  mounted() {
    this.quill = new Quill(this.$refs.quillContainer);
    this.quill.on("text-change", () => {
      this.value = this.quill?.root.innerHTML ?? "";
      this.$emit("input", this.value);
    });
  },
  watch: {
    value() {
      if (this.value !== this.quill?.root.innerHTML) {
        this.quill?.pasteHTML(this.value);
      }
    },
  },
});
</script>

One more addition snuck in there — we had to give a type to this.$refs.quillContainer to prevent TypeScript from complaining that this.$refs doesn’t exist! These are Vue basics TypeScript, c’mon.

It turns out Vue 3 doesn’t define a this.$refs attribute on its component types by default as Vue 2 did — the classic "template refs" that Vue has used up till now to make child elements accessible isn’t exactly deprecated, but it has been effectively superseded by differet Composition API -based mechanisms. However, we may still want to use classic this.$refs either because we’re incrementally migrating components to Vue 3, or we just prefer Options API component structure (totally valid). In an ideal world strong types here would not just be easy, but obvious.

It’s been suggested elsewhere that extending the global module-level type declarations for Vue is the answer, similarly to this method the Vue 3 docs suggest should be used to get TypeScript to play nice with Vuex’s this.$store.

I don’t like the module-level type approach for either of these use cases. In broad strokes, it feels too magical to me — globally augmenting Vue’s component type behind the scenes in a way that’s invisible to component code. More concretely, it doesn’t allow you to define the exact types each element from this.$refs might have; you must define them all as some general type like any or HTMLElement This is a huge limitation, when refs can point at any element type or even Vue component under the sun — for instance, our QuillEditor component. When accessing this.$refs.quillEditor from a parent, I should get perfect type information flowing to inform me of how to use (and avoid mis-using) the child component’s public methods and data.

We already saw how we can strong-type basic HTML elements in this.$refs using Vue 3’s setup method. Let’s take another look:

  setup() {
    return {
      quill: null,
    } as {
      quill: Quill | null;
      $refs: {
        quillContainer: HTMLElement;
        someInputElement: HTMLInputElement;
        someChildVueComponent: ...? what goes here ?...
      },
    };
  },

Now, if we write this.$refs.quillContainer.apend or any other typo/mistake in our parent component code, type checking catches on and reports the bug. Beautiful!

However, there’s still one hanging question — ? what goes here ? above. In Vue 2 we got this same level of comprehensive type checking for our child Vue components, not just basic HTML elements. We can have this in Vue 3; it just takes a little more TypeScript magic this time around.

Using Vue 3 Component Classes as Types

You may have noticed that during our trek from Vue 2 to Vue 3, our export interface QuillEditor type definition disappeared. What type will our components utilizing QuillEditor import, so that they know what public interface they have to work with?

We could redefine and export a separate QuillEditor interface that extends…​ Vue? Something? and matches our new setup return type (spoiler: in Vue 3, interface X extends Vue doesn’t work anymore). But this is a lot of extra typing (pun not originally intended, but accurate), and the auxillary component types would be prone to getting out of sync with the actual data returned from component setup. There’s a better way.

The secret is TypeScript’s InstanceType. I hadn’t encountered this mechanism before delving into Vue 3, but its purpose and function are fairly simple. From the TypeScript docs:

InstanceType<Type>

Constructs a type consisting of the instance type of a constructor function in Type.

class MyCls {
  x = 0;
  y = 0;
}
type InstanceOfMyCls = InstanceType<typeof MyCls>;

typeof in the above is simply TypeScript parlance for "use the type of this class, not its value" (the TypeScript type checker tsc will helpfully suggest this when you try InstanceType<MyCls> directly). Here are the docs on that for those that would like to read further.

Combining these, we can finally construct our final Vue 3 component instance type. Recall our Vue 2 FancyComponent example from earlier — in Vue 3 it would look like this:

fancy-component.vue
<template>
  <QuillEditor ref="quillEditor" />
</template>

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

import QuillEditor from "./quill-editor.vue"

export default defineComponent({
  name: "FancyComponent",
  components: { QuillEditor },
  setup(): {
    return {} as {
      $refs: {
        quillEditor: InstanceType<typeof QuillEditor>;  // the secret TypeSauce
      };
    };
  },
  mounted() {
    console.info(this.$refs.quillEditor.valu);
    // TypeError: `valu` does not exist on type `QuillEditor`
    // reported at compile time. Bug avoided!
  },
});

I go so far as to export component types directly from my components, with this line after each component’s class definition. It’s a little extra boilerplate, but to me feels more clear about the intentions of types flowing around the codebase:

in fancy-component.vue:
const QuillEditor = defineComponent({
  ...
});

export type QuillEditorComponent = InstanceType<typeof QuillEditor>;
export default QuillEditor;
in parent component:
import QuillEditor, { QuillEditorComponent } from "./quill-editor.vue";
// OR, separate out explicit `type` import, if you prefer:
import QuillEditor from "./quill-editor.vue"
import type { QuillEditorComponent } from "./quill-editor.vue";

export default defineComponent({
  name: "ParentComponent",
  components: { QuillEditor },
  setup(): {
    return {} as {
      $refs: {
        quillEditor: QuillEditorComponent;
      };
    };
  },

A Type-Safe Future for Vue

As mentioned at the start of this post, some qualities inherent to Vue — things that make it the lovely, productive, empowering framework that it is — work against it when it comes to smoothly harmonizing with TypeScript. This is a huge challenge for library authors generally, and the Vue team has been frankly doing an amazing job improving the TypeScript story in every way they can. There will always be edge scenarios, legacy component code, incremental migrations, and situations neither I or they could ever dream of that Vue and TypeScript will be used together in.

These situations are impossible to perfectly plan for, but that’s where Vue’s strength of community comes in. My hope is that as more and more people discover the wonderful benefits of both Vue 3, TypeScript, and the combination thereof, we’ll see more and more writing, sharing of knowledge, and eventually documentation on how to use these two fantastic tools together effectively.

While using the combination in a couple of moderately-sized apps myself, I’m enjoying the setup more and more every day. While completely workable right now, with a bit of tinkering to find the right patterns, I’m confident Vue support and docs will only get better from here. Component hierarchies representing rich UIs and apps are only getting more and more complex. In terms of not only bugs prevented when working with these systems, but also developer productivity and quality of life accrued by the ability to refactor without fear, the value of TypeScript to me is inestimable. When it comes to creativity, stress is the mind-killer, and I’ll happily invest in any tools and patterns that actively combat it.

So there you have it — I hope this essay has been at least somewhat helpful to you on your own Vue 3 journey. It’s a never-ending process of discovery; I’m still working out my own preferred techniques in a number of areas I hope to write about soon, but I’d welcome any ideas, discussion, or friendly pointers in the meantime:

  • Vue template type-safety through TSX or VTI (Vetur Terminal Interface)

  • Strong-typing for CSS Modules

We welcome any thoughts, feedback, or questions you have about the contents of this article — please open an issue or pull-request at https://github.com/e2org/writing, add the tag vuetypescript, and we’d love to talk with you there!