Generically Typed Vue Components with Composition API
Heads up
Vue 3.3 was released on May 11, 2023 with built-in support for TS generics in SFC components. This article is still relevant for older versions of Vue.js but you should use the official way instead.
Last year, I covered how to create generic Vue.js components. But as I have used it more, I found situations where it doesnât work as intended or outright broke especially with slots. In this article, I share another way that is more robust and flexible and it uses the composition API.
What are generic-typed components?
To refresh your memory, generics enable your functional units (functions and classes) to operate on a dynamic type that is determined when it is used instead of a fixed one. This allows for greater flexibility and makes these generic units highly re-usable.
So when we apply this concept to components you get the same outcome, a highly re-usable and very flexible component. These components existed for a while in the Vue.js ecosystem, but we never had a good way to properly type-check them when we are using them.
A very good example of this is a select input component. Normally you would settle for selecting primitive values. Like strings or numbers, but with very rich datasets you may want the user to be able to choose an object. You can think of components like VueMultiSelect as a good example of such components.
What we want here, is volar to be able to deduce the props/slots/emit generic types for these components and allow for type-checking and autocompletion.
But at the time of this writing, Vue.js doesnât have a way to define such generic components. But as you read through this article, it might be easier than you think without hacking too much.
Defining components in Setup
You can define components in your componentâs setup
function and use it in the template. This is the first part you need to know before you can formulate this workaround. That means it is possible to dynamically define a component and hook it up for your template. It would be similar to this:
vue<script setup lang="ts">
import { defineComponent, PropType, h } from 'vue';
const ChildComponent = defineComponent({
props: {
value: {
type: null as unknown as PropType<unknown>,
required: true,
},
},
// just renders a div
render: () => h('div'),
});
</script>
<template>
<!-- Works! -->
<ChildComponent value="Hello" />
</template>
But this is hardly enough, you still cannot make generic components out of it.
Generic props
Letâs start with a simple goal. We need to define a generic component that accepts a value
prop that can be of any type the consumer requires.
For that reason, we will need to wrap it within a function. Functions can be generic and they can relay that information to the dynamic component definition. We can call this âcomponent factoryâ which I will refer to later.
vue<script setup lang="ts">
import { defineComponent, PropType } from 'vue';
function useGenericComponent<TValue = unknown>() {
const GenericComponent = defineComponent({
props: {
value: {
type: null as unknown as PropType<TValue>,
required: true,
},
},
// just renders a div
render: () => h('div'),
});
return GenericComponent;
}
interface User {
id: number;
name: string;
}
const StringComponent = useGenericComponent<string>();
const UserComponent = useGenericComponent<User>();
</script>
<template>
<StringComponent value="hello" />
<UserComponent :value="{ id: 1, name: 'Awad' }" />
</template>
This is a very cool way to create such components. However, our example here is lacking a template, and we know that writing render functions isnât exactly a fun experience.
Ideally, we want to be able to use Vueâs SFC to enable us to fully utilize the template syntax and the compilerâs optimizations.
Using SFC as a base component
Volar has a setting called âtakeover modeâ which allows it to âhijackâ the typing server for TypeScript which I recommend for many reasons. The reason that matters here, is it allows you to import Vue.js components from .vue
SFC files into your regular .ts
files giving you full type information on that imported component.
So letâs move our component code into an SFC:
vue<template>
<pre>{{ value }}</pre>
</template>
<script setup lang="ts">
import { defineProps, PropType } from 'vue';
const props = defineProps({
value: {
type: null as PropType<unknown>,
required: true,
},
});
</script>
We dropped the as TValue
casting since we have no way to communicate that in an SFC component file which is part of the problem, but that doesnât matter as you will always need to treat the value
prop as unknown
or any constrained base type you choose. After all, when you design a generic function or class, you cannot make assumptions about the actual type the consumer is going to use. You could introduce restrictions with extends
but thatâs beside the point.
Now back in the âcomponent factoryâ function, we can import the base component definition from the Vue file and it will be fully typed:
tsimport BaseGenericComponent from './BaseGenericComponent.vue';
function useGenericComponent<TValue = unknown>() {
// What now?
}
Now that we got the generic component with all its template rendering glory imported, how can we use it in a way that makes our typescript tooling understand the value
prop type correctly?
This is an interesting problem with a few interesting solutions. In a nutshell, we need a way to pass down the generic type information and replace the value
prop type with it.
One way is to shadow the prop types of the imported component with a generic one. I prefer this approach since it is less intrusive and doesnât require doing far-fetched things to get it to work. All we have to do is wrap the original imported component with a very thin component layer that has the same props but they are generic instead. This brings us back to higher-order components as they will serve as that kind of layer.
The thinnest of layers we can make in Vue.js is a functional component, and with Vue 3 we can easily just use a single setup function as an argument to defineComponent
to build it.
tsimport { defineComponent, h } from 'vue';
import BaseGenericComponent from './BaseGenericComponent.vue';
function useGenericComponent<TValue = unknown>() {
const wrapper = defineComponent((props) => {
// Returning functions in `setup` means this is the render function
return () => h(BaseGenericComponent, props);
});
return wrapper;
}
This looks simple enough but we didnât type the props
yet for this thin layer. You could try to re-build the same component types from BaseGenericComponent
:
tsimport { defineComponent, h } from 'vue';
import BaseGenericComponent from './BaseGenericComponent.vue';
interface BaseProps<TValue> {
value: TValue;
}
function useGenericComponent<TValue = unknown>() {
const wrapper = defineComponent((props: BaseProps<TValue>) => {
// Returning functions in `setup` means this is the render function
return () => h(BaseGenericComponent, props);
});
return wrapper;
}
This would work very well, assuming we have simple components like this one. But if you have a component with a lot of props, re-building the type yourself like this sounds like a chore and needless to say, you will have a maintenance burden to keep both of the prop definitions in BaseGenericComponent.vue
to match the BaseProps
interface which is not guaranteed and is almost likely wonât hold for long.
So the first question is, how can we get the prop types of an imported SFC?
Extracting Prop Types from SFC components
You need here a utility type that extracts the prop types from component definition value, I donât think there is such a utility type yet available. Luckily there is a way to do this with some infer
keyword sorcery.
tsexport type ExtractComponentProps<TComponent> =
TComponent extends new () => {
$props: infer P;
}
? P
: never;
This ExtractComponentProps
type accepts a component definition type, whose instance will have an internal $props
Object of type P
. So return that type P
. Otherwise, it is not possible to infer the props type.
By putting this together with what we had before, we now need to remove all generic properties from the original component prop types and then re-add it as the generic type. To do the removal bit, we can use the Omit
utility type that is available in TypeScript like this:
tsOmit<
ExtractComponentProps<typeof BaseGenericComponent>,
'value'
>;
To understand how it works, check the following diagram
To add the generic prop back, we can either use extends
or &
type operator. Both work fine so go with whatever you prefer:
tsimport { defineComponent, h } from 'vue';
import BaseGenericComponent from './BaseGenericComponent.vue';
import { ExtractComponentProps } from './types';
interface GenericProps<TValue>
extends Omit<
ExtractComponentProps<typeof BaseGenericComponent>,
'value'
> {
value: TValue;
}
function useGenericComponent<TValue = unknown>() {
const wrapper = defineComponent(
(props: GenericProps<TValue>) => {
// Returning functions in `setup` means this is the render function
return () => h(BaseGenericComponent, props);
},
);
return wrapper;
}
Here we inferred the full prop types from the imported SFC, then removed the generic props with the TypeScript Omit
built-in utility type. Then redefined the omitted props as a generic.
This gives us exactly what we need, and you can use this to be confident about the prop types you send to your newly-created generic component.
Here is an example of how this would work:
vue<script lang="ts" setup>
import { useGenericComponent } from './genericComponent';
const StringComponent = useGenericComponent<string>();
interface User {
id: number;
name: string;
}
const ObjectComponent = useGenericComponent<User>();
</script>
<template>
<StringComponent value="hello" />
<ObjectComponent :value="{ id: 1, name: 'Awad' }" />
</template>
Heads up
Donât get confused with the script
and template
order, this
just makes it clearer in terms of reading order but feel free
to use whatever order you prefer.
In that example, if you try to send anything else to value
prop, you should be getting type errors when using either volar
or vue-tsc
. Here it is in action:
Generic Slots
Now that weâve managed to define generic props, what about slots?
This is slightly more complex since our simple functional layer doesnât convey slot information easily from the function definition.
But letâs expose oldValue
and currentValue
properties on the slot properties of the GenericComponent
.
In the SFC it would look similar to this:
vue<template>
<pre>
<slot :current-value="value" :old-value="oldValue">
{{ value }}
</slot>
</pre>
</template>
<script setup lang="ts">
import { watch, defineProps, ref, PropType } from 'vue';
const props = defineProps({
value: {
type: null as PropType<unknown>,
required: true,
},
});
const oldValue = ref<unknown>();
watch(
() => props.value,
(_, oldVal) => {
oldValue.value = oldVal;
},
);
</script>
With this out of the way, we now need a way to expose the slot information in the component factory function we created.
I think the easiest way is to manually augment the returned wrapper type with $slots
property that describes what slots the component has and what each slot offers. I learned this type-casting from vue-router
source code.
tsimport { defineComponent, h, VNode } from 'vue';
import BaseGenericComponent from './BaseGenericComponent.vue';
import { ExtractComponentProps } from './types';
interface GenericProps<TValue>
extends Omit<
ExtractComponentProps<typeof BaseGenericComponent>,
'value'
> {
value: TValue;
}
interface GenericSlotProps<TValue> {
currentValue: TValue;
oldValue: TValue;
}
export function useGenericComponent<TValue = unknown>() {
// remember to grab the slots object off the second argument
const wrapper = defineComponent(
(props: GenericProps<TValue>, { slots }) => {
// Returning functions in `setup` means this is the render function
return () => {
// We pass the slots through
return h(BaseGenericComponent, props, slots);
};
},
);
// Cast the wrapper as itself so we do not lose existing component type information
return wrapper as typeof wrapper & {
// we augment the wrapper type with a constructor type that overrides/adds
// the slots type information by adding a `$slots` object with slot functions defined as properties
new (): {
$slots: {
// each function correspond to a slot and its arguments are the slot props available
// this is the default slot definition, it offers the `GenericSlotProps` properties as slot props.
// it should return an array of `VNode`
default: (arg: GenericSlotProps<TValue>) => VNode[];
};
};
};
}
Two major changes to note here. First, we grabbed the slots
argument so that we can pass it through to the render function. This enables our component to pass whatever slots the consumer is trying to use, so it works with default and other named slots as well.
Secondly, we cast the wrapper
to itself and augmented it with an additional type. This is a weird casting for sure but I added comments to break it down for you.
With this in hand, you can try the example again with slots:
vue<template>
<StringComponent
:value="str"
v-slot="{ currentValue, oldValue }"
>
<span>current: {{ currentValue }}</span>
<span>old: {{ oldValue }}</span>
</StringComponent>
<ObjectComponent
:value="userObj"
v-slot="{ currentValue, oldValue }"
>
<span>current: {{ currentValue }}</span>
<span>old: {{ oldValue }}</span>
</ObjectComponent>
</template>
If you try that example with volar
, you will notice that all slot props types are correctly inferred. Running vue-tsc
also catches any type-errors in these slots.
Generic Component Events
Now that you have both generic props and slots, the last thing we can make generic is events emitted by $emit
API. This would be made possible by re-casting the $emit
option similar to what we did with slots.
tsimport { defineComponent, h, VNode } from 'vue';
import BaseGenericComponent from './components/BaseGenericComponent.vue';
import { ExtractComponentProps } from './types';
// We also omit the `onChanged` event so we can overwrite it, same case as the `value` prop
// This is because events and props are treated the same in Vue 3 except that events have `on` prefix
// So if you want to convert an event to a prop you can follow that convention (`changed` => `onChanged`)
type NonGenericProps = Omit<
ExtractComponentProps<typeof BaseGenericComponent>,
'value' | 'onChanged'
>;
interface GenericProps<TValue> extends NonGenericProps {
value: TValue;
}
interface GenericSlotsProps<TValue> {
currentValue: TValue;
oldValue: TValue;
}
export function useGenericComponent<TValue = unknown>() {
const wrapper = defineComponent(
(props: GenericProps<TValue>, { slots }) => {
// Returning functions in `setup` means this is the render function
return () => {
// Event handlers will also be passed in the `props` object
return h(BaseGenericComponent, props, slots);
};
},
);
return wrapper as typeof wrapper & {
new (): {
// Same trick as `$slots`, we override the emit information for that component
$emit: {
(e: 'changed', value: TValue): void;
};
$slots: {
default: (arg: GenericSlotsProps<TValue>) => VNode[];
};
};
};
}
Note that events and props are both treated equally in the type information, which is why we need to remove the old changed
event so that we can replace it with a generic version in the $emit
object definition.
We donât have to re-bind the events in the wrapper because of the new features of Vue 3âs VNode where events and props are treated the same so they will be passed along with the props
option.
vue<script lang="ts" setup>
import { useGenericComponent } from './genericComponent';
const StringComponent = useGenericComponent<string>();
interface User {
id: number;
name: string;
}
const ObjectComponent = useGenericComponent<User>();
function onChange(value: User) {
console.log(value);
}
</script>
<template>
<!-- đ This should complain now in Volar due to type error -->
<StringComponent
:value="str"
v-slot="{ currentValue, oldValue }"
@changed="onChange"
>
<div>current: {{ currentValue }}</div>
<div>old: {{ oldValue }}</div>
</StringComponent>
<ObjectComponent
:value="userObj"
v-slot="{ currentValue, oldValue }"
@changed="onChange"
>
<div>current: {{ currentValue }}</div>
<div>old: {{ oldValue }}</div>
</ObjectComponent>
</template>
In this last example, you will notice that StringComponent
event binding complains due to onChange
not accepting a string
type. This is what we want, now each typed version of this component is also strict about the events which makes it much safer to use.
Practical Generic Components
Because both StringComponent
and ObjectComponent
have the same definition, you might be hesitant to like this approach due to how it is spawning multiple definitions of the same component. But to me, while I didnât like this at first it still made sense in terms of the type and what purpose it delivers. In other words, it is immediately clear that StringComponent
has something to do with the type String
which wouldnât make any sense otherwise.
In hopes of convincing you, letâs build something more practical that makes use of all the previous parts. For example, a âSelectInputâ component.
This SelectInput
component should:
- Accept a list of options.
- Emit the selected value and support
v-model
API (acceptsmodeValue
prop and emitsupdate:modelValue
event). - Expose an
item
slot to customize each optionâs UI in the floating menu. - Expose a
selected
slot to customize the selected option UI.
I wonât go into the exact details of building this component. Instead, I will leave you with a live example that puts together what weâve learned so far.
Heads up
This example doesnât show the typescript features due to volar
not being available in an online web environment. Make sure to
clone/download the project locally and try it out with your
VSCode with volar
enabled with takeover mode.
After youâve read that example notice how clear it is what each select input instance deals with. A PetList
contains Pet
objects, while a OwnerList
deals with Owner
objects strictly.
Conclusion
I have used this pattern to create a variety of generic components. Starting with select inputs similar to the example above. A few other examples where I found this useful are tooltips, menus, and modals.
Hopefully in the future it will become easier to define generic components, one notable RFC addresses this. In case you wanted more on this topic, there is an excellent talk by @pikax_dev on this matter here.
In this article, you learned how to leverage the composition API to build components during the setup
cycle. Also, youâve learned how to take advantage of that to build generic components with strict typing capabilities for props, slots, and emitted events.