Abdelrahman Awad

Generically Typed Vue.js Components

Published Aug 12, 2021 |

One of the most productive features of TypeScript is Generics.

Generics allow you to create reusable bits of code, be it classes or functions and once you learn and understand them, you won't stop using them.

But it has always eluded me how to make Vue components with them, Let's explore our options.

Prerequisites

This article...

  • assumes you are using volar for Vue.js TypeScript tooling.
  • is a Vue.js 3 article
  • focuses on the composition API

The problem

Let's sketch an example of a component that will benefit from using generics, the simplest best example I can think of is a "select" or a "multi-select" component.

Such components always have an ecosystem of features operating on a specific type.

For example, if you are passing a string option to the component, It only makes sense it will only provide string for the model value and will operate on strings in general.

That's also true for complex values, a list of users will have a value of a user object and will perform operations and emits events with the user type.

The easy way out is to use any or better yet unknown, so this component is often implemented like this:

vue<template>
  <div>
    <!-- Template is irrelevant -->
  </div>
</template>

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

export default defineComponent({
  name: 'InputSelect',
  props: {
    options: {
      type: Array as PropType<unknown[]>,
      required: true,
    },
    value: {
      type: null as unknown as PropType<unknown | undefined>,
      default: undefined as unknown,
    },
  },
  emits: {
    change: (payload: unknown) => true,
  },
});
</script>

The problem with this component now is when you use it, you can never ensure type safety when passing values or receiving them.

Usually you will use the InputSelect like this:

vue<template>
  <InputSelect
    :options="options"
    :value="selectedOption"
    @change="handleChange"
  />
</template>

<script lang="ts">
import { defineComponent, ref } from 'vue';
import InputSelect from './InputSelect.vue';

export default defineComponent({
  components: {
    InputSelect,
  },
  setup() {
    const options = ref(['1', '2', '3']);
    const selectedOption = ref('');

    function handleChange(option: string) {
      console.log(option);
    }

    return {
      options,
      selectedOption,
      handleChange,
    };
  },
});
</script>

Notice that you will get an error with @change being assigned to handleChange.

This is because handleChange expects a string type while the InputSelect may pass anything to the @change handler. After all, it is typed as unknown so anything goes. This forces you to cast the value sent from the component before you can use it:

tsfunction handleChange(option: unknown) {
  const str = option as string;
  // do stuff...
}

This is unfortunate because you know for a fact that option value will always be a string.

If only there was a way to make your editor (vscode + volar) understand that 🤔.

The struggle

Wrapper Function

One idea that you will find after a quick search is to use a wrapper generic function:

tsimport { defineComponent, PropType } from 'vue';

function defineGenericComponent<T = unknown>() {
  return defineComponent({
    props: {
      options: {
        type: Array as PropType<T[]>,
        required: true,
      },
      value: {
        type: null as unknown as PropType<T | undefined>,
        default: undefined as unknown,
      },
    },
    emits: {
      change: (payload: T) => true,
    },
  });
}

This looks promising, but you cannot use it inside an SFC because of how the compiler works and how it assigns the render function to the default export. Also even if it worked, there is no good way to use it:

tsimport { defineComponent } from 'vue';
import InputSelect from './InputSelect.vue';

export default defineComponent({
  components: {
    InputSelect, // You cannot easily cast this
  },
});

Now you are stuck, because InputSelect is in TypeScript's value-space. Meaning you cannot really cast it because you need something for it to be cast to, consider this:

tsconst arr: Array<unknown> = ['1', '2', '3'];

// ✅ Works
const strArr = arr as Array<string>

const InputSelect = defineComponent(...);

const StrInputSelect = InputSelect as // ???

You can probably get something working with the exported DefineComponent type from vue but it is complicated.

Named Exports

A feature that's often ignored or not used often is to use named exports with SFC modules. After all, they are perfectly valid ESM modules and you can have named exports and import them individually without importing the component itself.

Here is how it works:

SomeComponent.vuetsconst Ctor = defineComponent({
  // ...
});

export function logAnything() {
  console.log('Anything!');
}

export default Ctor;
Whatever.vuets// You can import the named exports
import { logAnything } from './SomeComponent.vue';

This is not used often because there is little use to it in production apps, at least from my experience. However, if you do have neat use-cases feel free to write about them!

Now, how can we use this to get a step closer to our generic component?

We can instead of exposing a generic component as the default, we could expose the generic wrapper function as a named export.

So we could do something like this:

tsimport { defineComponent, PropType } from 'vue';

function defineGenericComponent<T = unknown>() {
  return defineComponent({
    name: 'InputSelect',
    props: {
      options: {
        type: Array as PropType<T[]>,
        required: true,
      },
      value: {
        type: null as unknown as PropType<T | undefined>,
        default: undefined as unknown,
      },
    },
    emits: {
      change: (payload: T) => true,
    },
  });
}

export const GenericInputSelect = <T>() => {
  return defineGenericComponent<T>();
};

export default defineGenericComponent();

Then we can try to use it like this:

tsimport { defineComponent } from 'vue';
import { GenericInputSelect } from './InputSelect.vue';

export default defineComponent({
  components: {
    InputSelect: GenericInputSelect<string>(),
  },
});

Ha! because functions can take generic parameters we can finally tell TypeScript and volar about the component generic type!

One problem though, the component won't render anything. Actually, you will get this warning:

sh[Vue warn]: Component is missing template or render function.

The reason for this is the same one that prevented us from exporting the custom defineGenericComponent. To give you more insight, this is how the default export is compiled:

sh{
  name: "InputSelect"
  props: {options: {…}, value: {…}, ... }
  render: (_ctx, _cache, $props, $setup, $data, $options)
  __file: "src/components/InputSelect.vue"
}

Looks alright, lets see how the named export is compiled:

sh{ name: 'InputSelect', props: {…} }

So, where the heck is our render function?

We lost the template render information here, which is even more important than the type information we've set out to improve. We can't blame the compiler here as we are trying some really weird stuff.

The rule as I understand it is: The render function is added to the default export.

Mirroring the default export

So all we have to do to make the previous example work is to mirror the default export as the generic type we want. In other words, return it after casting it.

This could be your very first attempt:

tsimport { defineComponent, PropType } from 'vue';

function defineGenericComponent<T = unknown>() {
  return defineComponent({
    // ...
  });
}

const main = defineGenericComponent();

export const GenericInputSelect = <T>() => {
  return main as ReturnType<typeof defineGenericComponent>;
};

export default main;

The component will render correctly again, but the type information still doesn't work because typeof doesn't allow us to pass generics.

The casting we did just gave us our component back with unknown as the generic type which means we have done zero progress.

Let's analyze this on a deeper level, so typeof accepts a value-space identifier to infer its type. The keyword here is "value-space identifier", as we cannot use generics on function identifier names. We can only use them when we call the functions.

We can try doing some voodoo magic with infer keyword, but you will need someone better than me at TypeScript to figure out a way to do that and explain it properly.

Instead, I have a little trick up my sleeve.

So the only reason typeof doesn't work, is because of its limitations on value-space identifiers. But if only we could have some construct that can wrap the function while being generic, Actually, classes does that very well!

Classes can be generic, and double as a value-space identifier and a type-space identifier:

tsclass SomeClass<T> {}

const item = new SomeClass(); // used as a value!

// used as a type!
function doOp(param: SomeClass) {
  // ...
}

By re-writing the generic wrapper using a class instead while mirroring the default export as we've tried before, we should get what we need:

tsimport { defineComponent, PropType } from 'vue';

class InputSelectFactory<T = unknown> {
  define() {
    return defineComponent({
      name: 'InputSelect',
      props: {
        options: {
          type: Array as PropType<T[]>,
          required: true,
        },
        value: {
          type: null as unknown as PropType<T | undefined>,
          default: undefined as unknown,
        },
      },
      emits: {
        change: (payload: T) => true,
      },
    });
  }
}

const main = new InputSelectFactory().define();

export function GenericInputSelect<T>() {
  // This now will be casted correctly!
  return main as ReturnType<InputSelectFactory<T>['define']>;
}

export default main;

And finally, you can use it like this:

vue<template>
  <InputSelect
    :options="options"
    :value="selectedOption"
    @change="handleSelectionChange"
  />
</template>

<script lang="ts">
import { defineComponent, ref } from 'vue';
import { GenericInputSelect } from './InputSelect.vue';

export default defineComponent({
  components: {
    InputSelect: GenericInputSelect<string>(),
  },
  setup() {
    const options = ref(['1', '2', '3']);
    const selectedOption = ref<string>('');

    function handleSelectionChange(option: string) {
      console.log(option);
    }

    return {
      options,
      selectedOption,
      handleSelectionChange,
    };
  },
});
</script>

And you can use more complex types with it:

tsimport { defineComponent, ref } from 'vue';
import { GenericInputSelect } from './SelectInput.vue';

interface Tag {
  id: number;
  label: string;
}

export default defineComponent({
  components: {
    InputSelect: GenericInputSelect<Tag>(),
  },
});

I'm not a fan of the casting done here:

tsconst main = new InputSelectFactory().define();

export function GenericInputSelect<T>() {
  return main as ReturnType<InputSelectFactory<T>['define']>;
}

export default main;

Casting is usually seen as an "escape hatch" and its usage should be kept to a minimum, but there are situations where it is very safe. I argue this is one of those situations as there is no way the main component isn't the component we just defined.

And that's it, we managed to finally create a truly generic component with SFC support.

Note that you cannot use this technique with <script setup> in vue >= 3.2+, because you need control over what is exported, this is a hack after all.

If you want to see this in action, download this sandbox project:

Additional Reading

You can check out the original issue answered in the Vue next repo here.

There is an RFC proposal for something similar.

Conclusion

While it doesn't seem that we have an easy and official way to support generic components, it is relatively straightforward if you learn how to navigate the pitfalls we just did.

When should you use generic components? The answer is the same as when you should be using generics! But to cut the list down, I see them mostly used in form field components and UI builders.

I don't use this pattern a lot, and using unknown works fine for 90% of my cases, but for those who want the extra edge and getting fully type-safe this might be the way for you.

Thanks for reading 👋

Join The Newsletter

Subscribe to get notified of my latest content

You might also like