It’s been a while since I talked about generic types in Vue components, especially since they were last introduced in Vue 3.3.0. I’ve been using them a lot lately and I wanted to share some of the use cases where I think they can be really useful, especially with Input components. We will be covering 3 main types of input components and how can you use generic types to make them more type-safe and pleasant to use. In this article, I will talk about the Select input and how to craft a strict and clear API.

The problem

Often when you are building a select input, you pass in the options as a prop to avoid having to enumerate the objects declaratively in the template. But something you may have done or noticed with some 3rd party libraries is you are limited to having options satisfying a certain shape or value type. It mostly revolves around presenting the option item, it must have a value and it also should have a label to show to the user.

So there are many ways to model the options array, a couple of those could be:

  • An array of primitive values, like strings, here the string item serves as both the value and the label.
  • Array of objects, each object always has distinct properties serving as a label and value, commonly they are named label and value but it may vary depending on which 3rd party library you are using.

Both approaches have their cons, the first one is very limited as you must pass a value that is both presentable to the user and can be used within your data models as a value. Unless you are working with a simple form, this is not a good approach.

The second approach has more promise here, but you limit the developer who is using your component to always map their objects to the shape you are expecting. Since we are going with clarity through stricter types, this could be a great starting point, let’s start with that.

Such a component could initially look like this:

vue<script setup lang="ts">
const props = defineProps<{
  name: string;
  label: string;
  options: { label: string; value: string }[];
  placeholder?: string;
}>();

const model = defineModel<string>({
  default: '',
});
</script>

<template>
  <div>
    <label :for="name">{{ label }}</label>
    <select :id="name" :name="name" v-model="model">
      <option value="">
        {{ placeholder || 'Select an option' }}
      </option>
      <option
        v-for="opt in options"
        :key="opt.value"
        :value="opt.value"
      >
        {{ opt.label }}
      </option>
    </select>
  </div>
</template>

I’ve baked in some nice defaults like a placeholder and a default value to keep the empty option selected. However, there are a few problems with such a component in terms of developer usability.

The first problem is: it emits string as the selected value type, which is not always ideal.

Another issue is your options must conform to a specific shape, this is fine for many cases but limits and forces the developer to always map their objects to the shape you are expecting.

Here is a typical case where you have a collection of items of a certain shape, and you are forced to map it to be able to use it with the component:

tsconst users = [
  { id: 1, name: 'John' }, 
  { id: 2, name: 'Jane' }, 
];

const options = users.map((user) => ({
  label: user.name,
  value: user.id,
}));

Both of these issues make our component a bit of a pain to use, and we can do better with generic types.

Giving flexibility to props

Let’s bring generic types into the mix and see how we can utilize them to improve this component.

First, I would like to make our component a bit flexible, so it can accept either an array of strings OR an array of objects of any shape.

vue<script
  setup
  lang="ts"
  generic="TOption extends string | Record<string, unknown>"
>
const props = defineProps<{
  name: string;
  label: string;
  options: TOption[];
  placeholder?: string;
}>();
</script>

The template will break because we have no idea what the shape of the object is. But the is, we don’t have to know. We can let the user of our component decide how to present the option and how to map it to a value.


Let’s tackle option labels first, we can introduce an optionValue prop which is a function that takes in an option and returns a string to be used as the option label.

vue<script
  setup
  lang="ts"
  generic="TOption extends string | Record<string, unknown>"
>
const props = defineProps<{
  //...
  options: TOption[];
  optionLabel: (opt: TOption) => string;
}>();

// ...
</script>

<template>
  <!-- ... -->
  <option v-for="opt in options" :key="opt.value">
    {{ optionLabel(opt) }}
  </option>
  <!-- ... -->
</template>

When you try to pass the optionLabel to the props notice that you get type checks for the option argument. So regardless of what type of options you pass in, it will be inferred correctly and piped back to your props.

vue<script setup lang="ts">
const users = [
  { firstName: 'John', id: 1 },
  { firstName: 'Ahmed', id: 2 },
];
</script>

<template>
  <InputSelect
    :option-label="(opt) => opt.firstName"
    :options="users"
  />
</template>

Let’s tackle the option’s value next. We don’t want to force the user to choose a single prop to be used as the value, for example, maybe they want to select the id as the value. Alternatively, they may want a different property or even the entire option object. So how do we solve this?

Since the model value could be different than the option value, that means we need another generic type to represent that relationship. That means we have another generic type to introduce, let’s call it TValue.

vue<script
  setup
  lang="ts"
  generic="
    TOption extends string | Record<string, unknown>,
    TValue = TOption
  "
>
const props = defineProps<{
  name: string;
  label: string;
  options: TOption[];
  placeholder?: string;
  optionLabel: (opt: TOption) => string;
  optionValue: (opt: TOption) => TValue;
}>();
//...
</script>

The TValue generic type by default is the same as the TOption, this allows some flexibility where if the user didn’t specify an optionValue prop, then the value is the item itself.

To fix the reset of the component we need to make some changes to how we render the options and how we get each iteration keys, this is one way to do that:

vue<script
  setup
  lang="ts"
  generic="
    TOption extends string | Record<string, unknown>,
    TValue = TOption
  "
>
//...
const model = defineModel<TValue>();

// This helps us get the value of the option
// We can't rely on a falsy check to determine if the option is selected
// as the value could be 0
function optToValue(opt: TOption) {
  if (props.optionValue) {
    return props.optionValue(opt);
  }

  return opt;
}

// This helps us get a string key of the option
// You can use a different approach here
function toKey(opt: TOption): string {
  return JSON.stringify(optToValue(opt));
}
</script>

<template>
  <!-- ...-->
  <option
    v-for="opt in options"
    :key="toKey(opt)"
    :value="optToValue(opt)"
  >
    {{ optionLabel(opt) }}
  </option>
  <!-- ...-->
</template>

With that out of the way, now if you pass an optionValue prop to the component, you will get type checks for the model value argument and you won’t be able to bind it to an incorrect ref type.

In the following snippet, I created a selectedValue ref that is of type string and tried to bind it to the component while specifying the id as the value, and it errors out as expected.

vue<script setup lang="ts">
const users = [
  { firstName: 'John', id: 1 },
  { firstName: 'Ahmed', id: 2 },
];

const selectedValue = ref('');
</script>

<template>
  <InputSelect
    :option-label="(opt) => opt.firstName"
    :option-value="
      (opt) => opt.id // Errors out
    "
    :options="users"
  />
</template>

Bonus: a bit of UI

Let’s spice our UI up a bit, ideally, we would like to be able to style our options and the popup menu to give the component a bit of style. Since the select element is very limited when it comes to styling, we could use Open UI’s <selectlist>.

Heads up

If you are on the latest Chrome production release, open the chrome://flags page and enable the “Experimental Web Platform features” flag to get the <selectlist> element to render correctly.

You can re-build the input with divs and floating libraries like tippy or floating-UI for better cross-browser support but I chose the path of least resistance here. The same principles still apply in the next sections.

Adding the entirety of the component here will be massive, so here is a working example of the component with the UI and all the changes we made so far:

Now we have a good-looking component that is also type-safe, I went through the trouble of doing all that UI work just so we can now move to the next step and allow the user complete customization of the option content, which means slots are in order.

Typed Slots for the option content

Having an overridable option slot allows for complex rendering of the options, the user can choose to display an image, icon, or whatever they want based on the option value. Simply displaying string labels is not enough in many cases. This means we need to add an overridable option slot.

We can do that by wrapping our option content with <slot> tag, and we will give it a name=option to make it clear that it overrides the option content. here is how it would look:

vue<template>
  <!-- ... -->
  <option
    v-for="opt in options"
    :key="toKey(opt)"
    :value="optToValue(opt)"
  >
    <slot name="option" :option="opt">
      {{ optionLabel(opt) }}
      <!-- ... -->
    </slot>
  </option>
  <!-- ... -->
</template>

This allows us to keep the current render behavior of the options as a default while at the same time allowing the consumers of the component to override the content as they see fit.

Here is an example with some countries’ flags!

And the best part is we get the selected option preview for free because of the selectlist’s selectedoption element, so no extra work is needed there.

Notice that the option slot prop is strictly typed, and you get auto-completion for the option object properties.

Conclusion

We’ve created a select input that takes any shape of options and allows the user to customize the option content. We’ve also made the component airtight in terms of type safety, and we’ve done all that with the help of generic types.

Generic types usefulness is not only limited to props, but you can also use them with slots and events and quite commonly with v-model events.

And you saw a glimpse of the promise of Open UI and how it can help us with powerful and less JavaScripty components.

I hope this article was helpful and you learned something new. I will be covering more input components next week, so stay tuned for that.

Join The Newsletter

Subscribe to get notified of my latest content