Mastering Conditional Property Types with Vue 3.3 Generics

Jeroen Bach

·

9 min read ·

How can we change a property's type based on another property's value? For instance, return a single item by default, and an array of items when a multiple property is set. To demonstrate what I mean, see the recording below. Here, you can observe how the type changes based on the multiple attribute.

Code example

There are cases where you would like to reuse your component for both multi and single values. However, you don't want to introduce another property or emit event. For example, with a Select/MultiSelect component, you can make it smarter by allowing it to return an array of items when multiple is specified and a single item when it is not.

Read further to see how I managed to achieve this by leveraging generics in Vue 3.3. Generics allowed me to dynamically change a property's type based on another property's value.

Why Use Generics in Vue 3.3?

Generics in Vue 3.3 allow for the creation of flexible and reusable components that can adapt to different scenarios. By using generics, you can conditionally modify the type of a property based on the provided parameters, reducing the need for redundant code or additional props. For example, think of a component that can handle both single and multiple values depending on a flag—this kind of flexibility, this makes components much more versatile and powerful.

Understanding the Concept

TypeScript can automatically infer a generic type from a parameter in a generic function. This type can then be used to determine the type of another parameter or even the return type. By combining this with conditional typing, you can create a generic function that returns a different value based on the provided parameter.

Generics in components work similarly to those in regular functions. To understand this better, let’s first work through an example using plain TypeScript before integrating it into our Vue component.

TypeScript Example

// We infer the return type from the argument type.
function 
discovery
<
T
>(
arg
:
T
):
T
{
return
arg
;
} // String in, string out const
result
=
discovery
("Hello, World!" as string);

Now, we can narrow down the possibilities of this generic type by using extends, which allows us to create a new conditional type based on specified options. In the following example, I'm limiting the possibilities to just true and false. As a result, it returns an array of values when true and a single value when false.

function 
discovery
<
T
extends boolean>(
multiple
:
T
) {
type
ConditionalType
=
T
extends false ? string : string[];
return (
multiple
? ["item1", "item2", "item3"] : "item1") as
ConditionalType
;
} // multiple = true const
multiple
=
discovery
(true);
// multiple = false const
single
=
discovery
(false);

In this example, the discovery function takes a parameter multiple, which determines whether the return type is a single string or an array of strings.

This is great! When using this in a component however, we would also like the option to omit the parameter (attribute) completely. Like this:

<MultiSelect ... />
<MultiSelect ... multiple />

Let's enhance the usability of our function by allowing the argument to be omitted. To achieve this, we can mark the argument with a question mark (?) to denote that it is optional and add a third option to our conditional type: undefined. This will ensure that if nothing is specified, the function returns a single item as well.

function 
discovery
<
T
extends boolean | undefined>(
multiple
?:
T
) {
type
ConditionalType
= undefined extends T
? string :
T
extends false
? string : string[]; return (
multiple
? ["item1", "item2", "item3"] : "item1") as
ConditionalType
;
} // multiple = true const
multiple
=
discovery
(true);
// multiple = false const
single
=
discovery
(false);
// multiple not present const
defaultSingle
=
discovery
();

Note that we had to swap the check around when introducing undefined (T extends undefined to undefined extends T). To understand why, read: Extra: Why undefined extends T and not T extends undefined?

Applying Generics in Vue Components

Let’s now apply this concept to a Vue component. We will create a VSelect component that can handle both single and multiple selections depending on a multiple prop.

<script lang="ts" setup generic="TMultiple extends boolean | undefined">
import { computed } from "vue";

// Lets return the correct type based on the TMultiple value.
// - TMultiple === undefined => string
// - TMultiple === false => string
// - TMultiple === true => string[]
type TSingleOrMultiple = undefined extends TMultiple
  ? string
  : TMultiple extends false
    ? string
    : string[];

interface Props {
  modelValue?: TSingleOrMultiple;
  options?: { key: string; value: string }[];
  multiple?: TMultiple;
}

const props = defineProps<Props>();
const emit = defineEmits<{
  (e: "update:modelValue", value: TSingleOrMultiple): void;
}>();

// Note: an empty attribute will result in an empty string "" value, therefore we check for false and undefined explicitly
const isMultiple = computed(
  () => props.multiple !== false && props.multiple !== undefined,
);

const value = ref<any>(props.modelValue);
watch(value, (v) => emit("update:modelValue", v));
</script>

<template>
  <select v-model="value" :multiple="multiple">
    <option disabled value="">
      Please select {{ isMultiple ? "multiple" : "one" }}
    </option>
    <option v-for="{ key, value } in options" :value="key">
      {{ value }}
    </option>
  </select>
</template>
Selected country:
Selected countries:

In this example, the VSelect component adapts its behavior depending on the value of the multiple prop, thanks to the use of generics and conditional types.

Practical Example using PrimeVue

The UI library PrimeVue provides both a Select and a MultiSelect component. Let's use what we've learned so far to combine them into one. Additionally, these components allow specifying any type of object array as the options. To enable this behavior, they've made their modelValue of type any. Let's improve that as well and add some extra assistance with choosing the optionValue property.

Here's an example of what we would like to achieve:

Code example
<script
  lang="ts"
  setup
  generic="
    TMultiple extends boolean | undefined,
    TOptionType,
    // As we cannot use undefined as a keyof type, we use
    // a workaround to just specify any and then check it
    // for undefined
    TOptionValue extends keyof TOptionType = any
  "
>
import { computed } from "vue";
import MultiSelect, { type MultiSelectProps } from "primevue/multiselect";
import Select from "primevue/select";

// Check whether we need to return the entire option object
// or just a property of that object
type TReturnType = undefined extends TOptionValue
  ? TOptionType
  : TOptionType[TOptionValue];

// Then we check whether we need to return a single value
// or an array of values
type TSingleOrMultiple = undefined extends TMultiple
  ? TReturnType
  : TMultiple extends false
    ? TReturnType
    : TReturnType[];

// Use the MultiSelectProps type from PrimeVue, but with some better typing
interface Props
  extends Omit<MultiSelectProps, "modelValue" | "options" | "optionValue"> {
  modelValue?: TSingleOrMultiple;
  optionValue?: TOptionValue;
  options?: TOptionType[];
  multiple?: TMultiple;
}

const props = defineProps<Props>();
const emit = defineEmits<{
  (e: "update:modelValue", value: TSingleOrMultiple): void;
}>();

// Note: an empty attribute will result in an empty string "" value,
// therefore we check for false and undefined explicitly
const isMultiple = computed(
  () => props.multiple !== false && props.multiple !== undefined,
);

const update = (value: any) => {
  emit("update:modelValue", value);
};
</script>

<template>
  <MultiSelect
    v-if="isMultiple"
    v-bind="props"
    :modelValue="modelValue"
    @update:modelValue="update"
  />
  <Select
    v-else
    v-bind="props"
    :modelValue="modelValue"
    @update:modelValue="update"
  />
</template>
<script lang="ts" setup>
import VSelect from "./VSelect.vue";
type Country = { id: number; name: string; code: string };
const countries: Country[] = [
  { id: 1, name: "United States", code: "US" },
  { id: 2, name: "Canada", code: "CA" },
  { id: 3, name: "United Kingdom", code: "GB" },
  { id: 4, name: "Australia", code: "AU" },
  { id: 5, name: "Germany", code: "DE" },
  { id: 6, name: "France", code: "FR" },
  { id: 7, name: "Japan", code: "JP" },
  { id: 8, name: "China", code: "CN" },
  { id: 9, name: "India", code: "IN" },
  { id: 10, name: "Brazil", code: "BR" },
  { id: 11, name: "Netherlands", code: "NL" },
];

const selectedCountry = ref<string | undefined>();
const selectedCountries = ref<number[]>([]);
</script>
<template>
  <div class="form-field">
    <label>Single select</label>
    <VSelect
      :options="countries"
      v-model="selectedCountry"
      optionLabel="name"
      optionValue="code"
    />
    <span>
      Selected country:
      <pre>{{ selectedCountry }}</pre>
    </span>
  </div>
  <div class="form-field">
    <label>Multiple select</label>
    <VSelect
      :options="countries"
      v-model="selectedCountries"
      optionLabel="name"
      optionValue="id"
      multiple
    />
    <span>
      Selected countries:
      <pre>{{ selectedCountries.join(", ") }}</pre>
    </span>
  </div>
</template>
Selected country:
empty
Selected countries:

To accomplish this, we performed the following steps:

  • Import PrimeVue Components: We imported both PrimeVue components and displayed them based on the multiple property.
  • Create a Generic Type: We created a generic type for TOptionValue, which is a keyof the TOptionType.
  • Define TReturnType: We then created a TReturnType that returns either TOptionType or one of its properties.
  • Develop TSingleOrMultiple Type: This TReturnType is utilized to create a TSingleOrMultiple type based on the multiple property.
  • Utilize Types in Props and Emit: Finally, all these types are used in the props and emit functions to provide additional assistance.

Conclusion

Using generics and conditional types in Vue 3.3 allows you to create flexible, reusable components that adapt to different use cases without adding unnecessary props or complexity. This approach provides several benefits:

  • Makes your code cleaner and more maintainable.
  • Improves type safety, reducing potential runtime errors.
  • Enhances the overall developer experience by providing clearer and more predictable behavior.

If you want to explore more about Vue 3.3 and generics, check out the official documentation or try implementing similar patterns in your own projects.

Extra: Why undefined extends T and not T extends undefined?

When you want to perform a conditional type check involving undefined, it's helpful to think in terms of whether T includes undefined. This is what undefined extends T checks—it asks whether undefined is a possible subtype of T.

If you were to use T extends undefined, it would only return true if T itself is exactly undefined. This is not the same as checking whether undefined could be part of T.

  • undefined extends T: This checks if undefined is part of the possible values of T. In other words, it will return true if T could be undefined (e.g., T is true | false | undefined).
  • T extends undefined: This checks if T is precisely undefined. If T has other values, like true or false, this condition would evaluate to false.