Welcome generics in Vue
Vue 3.3 has been just released 💚 And alongside other features and improvements, it includes a new compiler feature that I have been eagerly waiting on - generics support! This feature brings a new level of flexibility and reusability to Vue.js components as it allows us to create dynamically typed component slots or emits.
Generics in TypeScript
TypeScript's (TS) static typing can help detect errors early in the development process. Developers can use TypeScript's various tools to express the APIs of their components, composables, services, and other code elements. Generics are a crucial TypeScript feature and allow defining types or functions that can work with various data types without compromising type safety.
Generics in action - array function filterEven
Let's consider a simple example of a filterEven
function that takes an array as input and returns a new array with odd-indexed items filtered out:
export const filterEven = (array: unknown[]) =>
array.filter((_, index) => index % 2);
Although this function works in simple cases, it has a significant flaw: the returned array loses its original type and is outputted with the type unknown[]
instead:
From TypeScript's perspective, this behavior is expected because the input is described as type unknown[]
in the filterEven
function declaration. Since the function's purpose is to take in an array and return a filtered version of it, TS assumes that the array type won't change in the process.
However, this is not exactly how we wanted our function to behave. We don't want it to always return unknown[]
- it should return the same type of array that was passed in. To achieve this, we can use generics.
Instead of explicitly specifying the input array type as unknown[]
, we can declare it to be of any type that extends (is built upon) unknown[]
.
export const filterEven = <Array extends unknown[]>(array: Array) =>
array.filter((_, index) => index % 2);
This approach allows TypeScript to infer the type of the input array into the generic Array
and then apply it to the output array:
Voila, now the array returned by filterEven
function has the correct type! 🎉
Still don't understand how generics work in TypeScript? Don't worry! You might want to read further about them in the official handbook.
Generics syntax in Vue Single-File Components (SFCs)
Now you understand how generics might be useful in TypeScript (or any other statically typed language), but what about Vue? Let's write a component example where generics might come in handy.
Imagine a Tabs
component that:
- will handle the tab navigation by itself,
- will support complete customization of the rendered content for each tab via slots.
// Tabs.vue
<script lang="ts" setup>
import { ref, computed } from 'vue';
const props = defineProps<{
tabs: { id: string; heading: string; content: string }[];
}>();
const activeIndex = ref(0);
const activeTab = computed(() => props.tabs[activeIndex.value]);
</script>
<template>
<div>
<button
v-for="({ heading, id }, i) in tabs"
:key="id"
type="button"
@click="activeIndex = i"
>
{{ heading }}
</button>
<section class="content">
<slot :tab="activeTab" />
</section>
</div>
</template>
This component can be consumed as follows:
// App.vue
<script lang="ts" setup>
import Tabs from './Tabs.vue';
const productTabs = [
{
id: 'bestsellers',
heading: 'Bestsellers',
content: 'The best-selling articles in our store!',
products: [{ name: 'Gray sweater' }],
},
{
id: 'offers',
heading: 'Offers',
content: 'Here are some best offers tailored just to your liking 🧵',
products: [{ name: 'Green T-shirt' }],
},
];
</script>
<template>
<Tabs :tabs="productTabs" v-slot="{ tab }">
{{ tab.content }}
</Tabs>
</template>
Click here to see a working demo.
In this example, we pass an array productTabs
into the Tabs
component as a prop. The component then displays tab navigation buttons and allows the consumer to provide a tab template through a slot. This slot is scoped as the data tab
(containing information about the currently active tab) is passed into it.
It seems like everything has worked on the first try, but has it really? Let's take a closer look at the type of the tab
property that's passed into a slot template:
Did you notice anything missing? The issue here is similar to the one previously encountered in this article. The type of tab
property has been narrowed down to the type of the tabs
prop. However, in our case, the input type is broader than that as the products
field is being stripped down.
Okay, so how to fix it? The solution, of course, involves the use of generics! Instead of typing tabs
prop strictly like we did in previous example we should allow TypeScript to accept and infer specific type that fulfills the type { id: string; heading: string; content: string; }
:
// Tabs.vue
<script
lang="ts"
setup
generic="Tab extends { id: string; heading: string; content: string }"
>
import { ref, computed } from 'vue';
const props = defineProps<{
tabs: Tab[];
}>();
const activeIndex = ref(0);
const activeTab = computed(() => props.tabs[activeIndex.value]);
</script>
<template>
<div>
<button
v-for="({ heading, id }, i) in tabs"
:key="id"
type="button"
@click="activeIndex = i"
>
{{ heading }}
</button>
<section class="content">
<slot :tab="activeTab" />
</section>
</div>
</template>
Let's examine the implementation. First, we created a generic by adding generic="Tab extends { id: string; heading: string; content: string; }"
to the component <script>
section. Second, we used the Tab
generic to declare the type of tabs
prop as Tab[]
. By making these two changes, we informed TypeScript that the tabs
prop can be filled with any type, as long as it's an array, and each item is built on top of type { id: string; heading: string; content: string; }
.
And what about the consumer component? Is the slot type inferred correctly now? Let's take a look:
Excellent! The tab data is passed directly into the scoped slot with the correct, initial type! 🎉 This enhances the flexibility of our component, as the slot content can now easily use custom tab properties without sacrificing the benefits of static type checking.
Closing words
Generics support was a huge addition to Vue.js and its arrival is a significant milestone. The introduction of this feature simplified the process of representing dynamic types that are essential for achieving the required flexibility in specific scenarios. Also, the generic=""
attribute is a seamless and natural addition to Vue SFC syntax, which enhances the language's expressiveness. I hope to see more and more libraries (particularly UI ones) taking advantage of generics and improving their type safety!