Vue 3.3 - Generically Typed SFCs.
Vue 3 continues to provide excellent type support, and since v3.3, we've been able to use generic types our component definitions. Let's dive in and see how this works, and what benefits it can bring.
Getting Started
We're going to create a Select component using the headlessUI Listbox.
If you want to skip setting up the basic project and installing dependencies, you can grab the starter code from the starter
branch here and skip to the next section.
Firstly, I'm going to create a new Vue 3 + Vite project.
pnpm create vue@latest vue-type-generics && cd vue-type-generics
npm create vue@latest vue-type-generics && cd vue-type-generics
yarn create vue@latest vue-type-generics && cd vue-type-generics
And select the following options:
√ Add TypeScript? Yes
√ Add JSX Support? No
√ Add Vue Router? No
√ Add Pinia? No
√ Add Vitest? No
√ Add an End-to-End Testing Solution? No
√ Add ESLint for code quality? Yes
√ Add Prettier for code formatting? No
We'll also install headlessUI and tailwindcss:
pnpm add @headlessui/vue
pnpm add -D tailwindcss postcss autoprefixer
npm install @headlessui/vue
npm install -D tailwindcss postcss autoprefixer
yarn add @headlessui/vue
yarn add -D tailwindcss postcss autoprefixer
pnpm dlx tailwindcss init -p
npx tailwindcss init -p
yarn tailwindcss init -p
// tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [],
content: [
'./index.html',
'./src/**/*.{vue,js,ts,jsx,tsx}',
],
theme: {
extend: {},
},
plugins: [],
}
/* src/assets/main.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
Your package.json should look something like this:
{
"name": "vue-type-generics",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "run-p type-check build-only",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --noEmit",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
},
"dependencies": {
"@headlessui/vue": "^1.7.13",
"vue": "^3.2.47"
"vue": "^3.3.0-beta.3"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.2.0",
"@tsconfig/node18": "^2.0.0",
"@types/node": "^18.16.3",
"@vitejs/plugin-vue": "^4.2.1",
"@vue/eslint-config-typescript": "^11.0.3",
"@vue/tsconfig": "^0.3.2",
"autoprefixer": "^10.4.14",
"eslint": "^8.39.0",
"eslint-plugin-vue": "^9.11.0",
"npm-run-all": "^4.1.5",
"postcss": "^8.4.23",
"tailwindcss": "^3.3.2",
"typescript": "~5.0.4",
"vite": "^4.3.4",
"vue-tsc": "^1.6.4"
}
}
We can remove everything in the src/components
, as well as src/assets/base.css
and src/assets/logo.svg
. Finally, we can change the content of src/App.vue
to simply:
<script setup lang="ts">
</script>
<template>
<main>Hello, Vue 3.3 TS Generic Components!</main>
</template>
Creating a Select Component
Let's go ahead and create our component in src/components/VSelect.vue
with the following starter code, which is mostly taken from the headlessUI docs. I've added basic styling and added some props to make it broadly reusable.
<script setup lang="ts">
import {
Listbox,
ListboxLabel,
ListboxButton,
ListboxOptions,
ListboxOption,
} from '@headlessui/vue'
import { computed } from 'vue';
const props = defineProps<{
/**
* modelValue to implement a two-way binding
* see https://vuejs.org/guide/components/v-model.html
*/
modelValue: any
/**
* label for the input
*/
label: string
/**
* list of available items
*/
items: any[]
/**
* key to use to get the underlying value from a provided object
* see valueFn
*/
valueKey: string
/**
* key to use to get the display text from a provided object
* see displayFn
*/
displayKey: string
}>()
defineEmits<{
(e: 'update:modelValue', value: any): void
}>()
const valueFn = (o: any) => o[props.valueKey]
const displayFn = (o: any) => o[props.displayKey]
/**
* gets the full selected item, based on the selected value.
*/
const selectedItem = computed(() => props.items.find(i => valueFn(i) === props.modelValue))
</script>
<template>
<div class="w-72">
<Listbox @update:model-value="$emit('update:model-value', $event)">
<ListboxLabel>{{ label }}</ListboxLabel>
<div class="relative">
<ListboxButton class="relative w-full border text-left">
<span class="block truncate">{{ selectedItem ? displayFn(selectedItem) : 'Please Select...' }}</span>
</ListboxButton>
<ListboxOptions class="absolute max-h-60 z-10 bg-white w-full overflow-auto py-1 border">
<ListboxOption v-slot="{ active, selected }" v-for="item in items" :key="String(valueFn(item))"
:value="valueFn(item)" as="template">
<li class="relative select-none" :class="{
'bg-blue-100': active,
'bg-blue-50': selected && !active
}">
<span>{{ displayFn(item) }}</span>
</li>
</ListboxOption>
</ListboxOptions>
</div>
</Listbox>
</div>
</template>
We can then use this in our App.vue
like so:
<script setup lang="ts">
import { ref } from 'vue'
import VSelect from './components/VSelect.vue'
const people = ref([
{
id: 1,
name: 'Frodo',
age: 50
},
{
id: 2,
name: 'Gandalf',
age: 24_000
}
])
const person = ref()
</script>
<template>
<main>
<VSelect v-model="person" :items="people" label="Character" value-key="id" display-key="name" />
</main>
</template>
This works fine, but there are errors that we could potentially introduce in our source code that wouldn't be noticed until runtime. Right now we're having to use any[]
as the type of our items
prop. We could be providing users, countries, etc.
This is a problem - suppose we do change our above code by setting the valueKey
to person_id
instead. person_id
is clearly not going to result in anything useful, since our people
don't have this property! However, TypeScript won't complain and we won't see any issue until we actually use this in the browser.
<VSelect :items="people" v-model="person" label="Character" value-key="person_id" display-key="name" />
Generics!
So, how can we fix this? You guessed it - by utilising Generics in our VSelect
component! We want to restrict the valueKey
to be some key of whatever we provide as the items
prop. i.e. if we'd have to provide id
, name
, or age
for our current example. If we were instead choosing from a list of countries, which had the following interface:
interface Country {
country_code: string
country_name: string
}
Then we'd want to restrict valueKey
to be either country_code
or country_name
. The same goes for our displayKey
prop.
So, let's head back to our VSelect
component and add this feature:
<script setup lang="ts">
<script setup lang="ts" generic="TItem">
const props = defineProps<{
...
items: any[]
items: TItem[]
...
valueKey: string
valueKey: keyof TItem
...
displayKey: string
displayKey: keyof TItem
}>()
...
</script>
Now, whenever we use this component in our application, TypeScript will look at the items
we provide, and ensure that valueKey
and displayKey
are both keys of the item type! Neat. If we head on over back to App.vue
, you should now see an error in our template:
<VSelect
:items="people"
v-model="person"
label="Character"
value-key="person_id" // [!code error]
display-key="name"
/>
Error
Type "person_id"
is not assignable to type "id" | "name" | "age"
.ts(2322)
Great, now we'll get a warning straight away in our IDE before even switching to the browser and seeing it fail.
But wait, there's more! Currently in App.vue
we're initialising person
as ref()
, which gives it an inferred type of Ref<any>
. In a larger codebase we'd probably have a more precise type than this. If we know we want to capture the id of a user, then we'd want to ensure this has a type of Ref<number
> instead. So let's change this by altering the declaration to the following:
const person = ref()
const person = ref<number>()
Now, this introduces another way we can improve the restrictions on our valueKey
props. TypeScript would have no problem with us currently setting the valueKey
as name
. But there definitely is something wrong with this! The name
property of our people is of type string
, not number
.
So, ideally we want to further restrict our valueKey
to only allow keys where the type of that property returns the same type as modelValue
.
Firstly, let's add an additional generic type, TValue
to our VSelect
component, which will be type of the modelValue
prop.
<script setup lang="ts" generic="TItem">
<script setup lang="ts" generic="TValue, TItem">
...
const props = defineProps<{
/**
* modelValue to implement a two-way binding
* see https://vuejs.org/guide/components/v-model.html
*/
modelValue: any
modelValue: TValue
...
}>()
...
</script>
Now, to enrich the typing of our valueKey
property, we need to find all the keys of TItem
which have type TValue
. Credit to this SO answer for this nice KeyOfType
utility.
<script setup lang="ts" generic="TValue, TItem">
...
type KeyOfType<T, V> = keyof {
[P in keyof T as T[P] extends V ? P : never]: any
}
const props = defineProps<{
...
valueKey: keyof TItem
valueKey: KeyOfType<TItem, TValue>
...
}>()
...
...
</script>
Now, if we go back to App.vue
and try to use name
as our valueKey
, we get an error! We're now only allowed to use id
or age
, since these are the only numerical properties.
<VSelect
:items="people"
v-model="person"
label="Character"
value-key="name" // [!code error]
display-key="name"
/>
Error
Type "name"
is not assignable to type "id" | "age"
.ts(2322)
Back in our VSelect.vue
, we can only stop using any
types for our valueFn
and displayFn
, since we now know they'll be of type TItem
.
const valueFn = (o: any) => o[props.valueKey]
const displayFn = (o: any) => o[props.displayKey]
const valueFn = (o: TItem) => o[props.valueKey] as TValue
const displayFn = (o: TItem) => o[props.displayKey]
We can also specify that our update:modelValue
emit will emit a TValue
type, not any
:
defineEmits<{
(e: 'update:modelValue', value: any): void
(e: 'update:modelValue', value: TValue): void
}>()
We'll have one TS error in our component, specifically on our headlessUI's ListboxOption
. Luckily, it gives us a very clear error:
Error
Type 'TValue' is not assignable to type string | number | boolean | object | null | undefined
.ts(2322)
VSelect.vue: This type parameter might need an extends string | number | boolean | object | null | undefined
constraint.
It tells us we need to add an extends
constraint, so let's do that:
<script setup lang="ts" generic="TValue, TItem">
<script setup lang="ts" generic="TValue extends string | number | boolean | object | null | undefined, TItem">
...
</script>
And there we have it, a type-safe, extendable Select component in Vue 3.3, utilising generically typed components! You can find the full source code on the blog-final
branch here.
If you're interested in seeing more complex examples, check out the master
branch here with additional components, typing and props for customisation. You can see some of the deployed components at https://lucent-faloodeh-db7134.netlify.app/
For further reading on TypeScript Generics, see https://www.typescriptlang.org/docs/handbook/2/generics.html
And, the Vue RFC can be found here https://github.com/vuejs/rfcs/discussions/436