Better-typed Vue components


Sometimes you want to create a generic component that holds a list of similar items. Usually it is something like a menu, dropdown, or a select. I’ve seen it done like this:

<!-- MySelect.vue -->
<!-- DON'T DO THIS -->
<script lang="ts" setup>
export interface Option {
  label: string;
  value: string;
}

defineProps<{ options: Option[] }>();

const model = defineModel<string>();
</script>

<template>
  <select v-model="model">
    <option
      v-for="option in options"
      :value="option.value"
      :key="option.value"
    >
      {{ option.label }}
    </option>
  </select>
</template>

There are a couple of things wrong here:

  • If we need to change how <option> is rendered, we have to change the base component.
  • To use this component, we have to cast our data into Option (nowadays it is completely normal to copy data multiple times, so instead let’s just say that mapping is not very convenient).
  • The v-model type is hardcoded to string.

We can try fixing some of those issues:

<!-- MySelect.vue -->
<!-- A BIT BETTER, BUT WE ARE NOT DONE YET, DON'T DO THIS -->
<script lang="ts" setup>
interface Option {
  key: PropertyKey;
  value: string;
  label: string;
}

defineProps<{ options: Option[] }>();

defineSlots<{
  option: {
    item: Option,
  },
}>();

const model = defineModel<Option['value']>();
</script>

<template>
  <select v-model="model">
    <option
      v-for="option in options"
      :value="option.value"
      :key="option.key"
    >
      <slot name="option" :option="option" />
    </option>
  </select>
</template>

Now it’s much, much better than it used to be:

  • Proper slot support
  • key is properly typed and detached from the value

However, we still don’t have any way to change the value type, and we still have to modify Option every time we want to render a new property inside a slot. Imagine using a single Option type for the whole project. How long will it take before Option starts using unions like string | number just because we need to use the same property with different types?

But fear not, with Vue 3.3 we were blessed with generic components. Now we can add generic parameters to components just like we do for functions (components are functions after all, but I want to emphasize the fact that we can do all the same arcane rituals with components as with functions). The syntax is applicable to both SFC components:

<script lang="ts" setup generic="T extends string">
  defineProps<{
    foo: T;
  }>();
</script>

and non-SFC components:

defineComponent(<T extends string>(props: { foo: T }) => {
  return () => {};
}, {
  props: ['foo'],
});

Now we can apply it to our example:

<script lang="ts" setup generic="T">
defineProps<{ options: T[] }>();

const model = defineModel<string>();
</script>

<template>
  <select v-model="model">
    <option
      v-for="option in options"
      :value="???"
      :key="???"
    >
      <slot name="option" :option="option" />
     </option>
  </select>
</template>

But here comes an issue: we don’t know how to set key and value. Also, we still have a hardcoded model type, so let’s fix it. If you have some experience with Vue before, probably you saw the vue-multiselect library that consumes your data as is, without needing to map it into some predefined shape. This library solves this issue by using props like label and track-by that indicate what properties of the object should be used for things like key, value and so on. This approach works well in runtime, but wouldn’t it be great to do the same with TypeScript? Of course! So let’s dig a bit deeper into what we want to achieve:

  • v-model should support objects and plain values like string and number
  • key should point to the appropriate object key

To meet those criteria, I’ll conjure this little guy from the depths of oblivion:

KeyBy extends keyof T | undefined = undefined

He can look a bit intimidating, but don’t worry, he doesn’t bite.

  • keyof T marks our KeyBy to be a key of T (🤯)
  • keyof T | undefined union is required to make the assignment trick work.
  • keyof T | undefined = undefined declares a default for generic parameter, which allows us to do proper conditional typing inside the component
But can you just keyof T = never instead? Yes, but in this case error messages will be much more cryptic:

keyof T = never produces an error with undefined expected type for v-model expression instead of an actual type for T (the expected type is { a: number })

Then we need to make the defineProps macro happy:

const props = defineProps<{ options: T[], valueBy?: ValueBy, keyBy?: KeyBy }>();

Why are we doing optional keys via ? if we already marked our types as nullable via union? This is because the Vue SFC compiler has to transform our TypeScript macro into a proper runtime prop declaration with the required field. But instead of getting this info from the TypeScript itself, the compiler tries to guess if the type is nullable just by looking at it. And our little fella is just too complex for the SFC compiler to guess it correctly, so we need to help it a bit with a ?.

sfc-meme

Let’s also add conditional types to the defineModel macro:

type Model = ValueBy extends keyof T ? T[ValueBy] : T;
const model = defineModel<Model>();

And here is the fully assembled component:

<script lang="ts" setup generic="T, KeyBy extends keyof T | undefined = undefined, ValueBy extends keyof T | undefined = undefined">
const props = defineProps<{ options: T[], valueBy?: ValueBy, keyBy?: KeyBy }>();

type Model = ValueBy extends keyof T ? T[ValueBy] : T;
const model = defineModel<Model>();

function getValue(option: T) {
  if (props.valueBy) {
    return option[props.valueBy]
  }
  return option;
}

function getKey(option: T): PropertyKey {
  const key = props.keyBy ? option[props.keyBy] : option
  if (typeof key !== 'string' && typeof key !== 'number' && typeof key !== 'symbol') {
    throw new Error('Invalid key');
  }
  return key;
}
</script>

<template>
  <select v-model="model">
    <option
      v-for="option in options"
      :value="getValue(option)"
      :key="getKey(option)"
    >
      <slot name="option" :option="option" />
    </option>
  </select>
</template>

This solution still has some things to improve, like the ability to add functions instead of simple string keys and more compile-time checks for the objects. However, it is much better than beating one poor global type again and again.

Thank you for the read, and I hope this article helped you overcome the fear of generic components 😀