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:

Tap to play/pause

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.

Tap to play/pause

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.

Tap to play/pause

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 (accepts modeValue prop and emits update: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.