Dominar los tipos de propiedades condicionales con genéricos de Vue 3.3

Jeroen Bach

Jeroen Bach · Linkedin

9 min read ·

¿Cómo podemos cambiar el tipo de una propiedad según el valor de otra propiedad? Por ejemplo, devolver un solo elemento por defecto, y un array de elementos cuando se establece una propiedad multiple. Para demostrar lo que quiero decir, mira la grabación a continuación. Aquí puedes observar cómo el tipo cambia según el atributo multiple.

Ejemplo de código

Hay casos en los que querrías reutilizar tu componente tanto para valores múltiples como simples. Sin embargo, no quieres introducir otra propiedad o evento emit. Por ejemplo, con un componente Select/MultiSelect, puedes hacerlo más inteligente permitiéndole devolver un array de elementos cuando se especifica multiple y un solo elemento cuando no lo está.

Sigue leyendo para ver cómo logré esto aprovechando los genéricos en Vue 3.3. Los genéricos me permitieron cambiar dinámicamente el tipo de una propiedad según el valor de otra propiedad.

¿Por qué usar genéricos en Vue 3.3?

Los genéricos en Vue 3.3 permiten la creación de componentes flexibles y reutilizables que pueden adaptarse a diferentes escenarios. Al usar genéricos, puedes modificar condicionalmente el tipo de una propiedad según los parámetros proporcionados, reduciendo la necesidad de código redundante o props adicionales. Por ejemplo, piensa en un componente que puede manejar tanto valores simples como múltiples dependiendo de una bandera: este tipo de flexibilidad hace que los componentes sean mucho más versátiles y potentes.

Entendiendo el concepto

TypeScript puede inferir automáticamente un tipo genérico de un parámetro en una función genérica. Este tipo puede luego usarse para determinar el tipo de otro parámetro o incluso el tipo de retorno. Al combinar esto con tipos condicionales, puedes crear una función genérica que devuelva un valor diferente según el parámetro proporcionado.

Los genéricos en componentes funcionan de manera similar a los de las funciones regulares. Para entender esto mejor, primero trabajemos en un ejemplo usando TypeScript puro antes de integrarlo en nuestro componente Vue.

Ejemplo de TypeScript

// Inferimos el tipo de retorno del tipo de argumento.
function discovery<T>(arg: T): T {
  return arg;
}

// String en entrada, string en salida
const result = discovery('Hello, World!' as string);
//    ^ const result: string

Ahora, podemos reducir las posibilidades de este tipo genérico usando extends, lo que nos permite crear un nuevo tipo condicional basado en opciones especificadas. En el siguiente ejemplo, estoy limitando las posibilidades a solo true y false. Como resultado, devuelve un array de valores cuando es true y un solo valor cuando es 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);
//    ^ const multiple: string[]

// multiple = false
const single = discovery(false);
//    ^ const single: string

En este ejemplo, la función discovery toma un parámetro multiple, que determina si el tipo de retorno es un solo string o un array de strings.

¡Esto es genial! Sin embargo, cuando usamos esto en un componente, también nos gustaría tener la opción de omitir completamente el parámetro (atributo). Así:

<MultiSelect ... />

<MultiSelect ... multiple />

Mejoremos la usabilidad de nuestra función permitiendo que el argumento sea omitido. Para lograr esto, podemos marcar el argumento con un signo de interrogación (?) para denotar que es opcional y agregar una tercera opción a nuestro tipo condicional: undefined. Esto asegurará que si no se especifica nada, la función también devuelva un solo elemento.

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);
//    ^ const multiple: string[]

// multiple = false
const single = discovery(false);
//    ^ const single: string

// multiple no presente
const defaultSingle = discovery();
//    ^ const defaultSingle: string

Ten en cuenta que tuvimos que intercambiar la verificación al introducir undefined (T extends undefined a undefined extends T). Para entender por qué, lee: Extra: ¿Por qué undefined extends T y no T extends undefined?

Aplicando genéricos en componentes Vue

Ahora apliquemos este concepto a un componente Vue. Crearemos un componente VSelect que puede manejar selecciones simples y múltiples dependiendo de una prop multiple.

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

// Devolvamos el tipo correcto según el valor TMultiple.
// - 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
}>();

// Nota: un atributo vacío resultará en un valor de cadena vacía "", por lo que verificamos false y undefined explícitamente
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="">
      Por favor selecciona {{ isMultiple ? "múltiples" : "uno" }}
    </option>
    <option v-for="{ key, value: optionValue } in options" :key="key" :value="key">
      {{ optionValue }}
    </option>
  </select>
</template>
Selected country:
Selected countries:

En este ejemplo, el componente VSelect adapta su comportamiento dependiendo del valor de la prop multiple, gracias al uso de genéricos y tipos condicionales.

Ejemplo práctico usando ElementPlus

La biblioteca de UI ElementPlus proporciona un componente Select con una prop multiple. Usemos lo que hemos aprendido hasta ahora para hacer este componente type-safe. Además, este componente permite especificar cualquier tipo de array de objetos como opciones. Para habilitar este comportamiento, han hecho su modelValue de tipo any. Mejoremos eso también y agreguemos asistencia adicional para elegir la propiedad optionValue.

Aquí hay un ejemplo de lo que nos gustaría lograr:

Ejemplo de código
<script
  lang="ts"
  setup
  generic="
    TMultiple extends boolean | undefined,
    TOptionType,
    // Como no podemos usar undefined como keyof type, usamos
    // una solución alternativa para especificar any y luego verificarlo
    // para undefined
    TOptionValue extends keyof TOptionType = any
  "
>
import { computed } from 'vue';

// Verificar si necesitamos devolver el objeto de opción completo
// o solo una propiedad de ese objeto
type TReturnType = undefined extends TOptionValue
  ? TOptionType
  : TOptionType[TOptionValue];

// Luego verificamos si necesitamos devolver un solo valor
// o un array de valores
type TSingleOrMultiple = undefined extends TMultiple
  ? TReturnType
  : TMultiple extends false
    ? TReturnType
    : TReturnType[];

interface Props {
  modelValue?: TSingleOrMultiple
  optionValue?: TOptionValue
  optionLabel?: keyof TOptionType
  options?: TOptionType[]
  multiple?: TMultiple
  placeholder?: string
  disabled?: boolean
  clearable?: boolean
  filterable?: boolean
}

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

// Nota: un atributo vacío resultará en un valor de cadena vacía "",
// por lo que verificamos false y undefined explícitamente
const isMultiple = computed(
  () => props.multiple !== false && props.multiple !== undefined,
);

function update(value: unknown) {
  emit('update:modelValue', value as TSingleOrMultiple);
}

// Function to help work with ElementPlus
function getAsString(value: unknown): string {
  return value as string;
}
</script>

<template>
  <ElSelect
    :modelValue="(modelValue as any)"
    :multiple="isMultiple"
    :placeholder="placeholder"
    :disabled="disabled"
    :clearable="clearable"
    :filterable="filterable"
    @update:modelValue="update"
  >
    <ElOption
      v-for="(option, index) in options"
      :key="index"
      :label="getAsString(props.optionLabel ? option[props.optionLabel!] : option)"
      :value="getAsString(props.optionValue ? option[props.optionValue!] : option)"
    />
  </ElSelect>
</template>
<script lang="ts" setup>
import VSelect from './VSelect.vue';

interface Country { id: number, name: string, code: string }
const countries: Country[] = [
  { id: 1, name: 'Estados Unidos', code: 'US' },
  { id: 2, name: 'Canadá', code: 'CA' },
  { id: 3, name: 'Reino Unido', code: 'GB' },
  { id: 4, name: 'Australia', code: 'AU' },
  { id: 5, name: 'Alemania', code: 'DE' },
  { id: 6, name: 'Francia', code: 'FR' },
  { id: 7, name: 'Japón', code: 'JP' },
  { id: 8, name: 'China', code: 'CN' },
  { id: 9, name: 'India', code: 'IN' },
  { id: 10, name: 'Brasil', code: 'BR' },
  { id: 11, name: 'Países Bajos', code: 'NL' },
];

const selectedCountry = ref<string | undefined>();
const selectedCountries = ref<number[]>([]);
</script>

<template>
  <div class="form-field">
    <label>Selección simple</label>
    <VSelect
      v-model="selectedCountry"
      :options="countries"
      optionLabel="name"
      optionValue="code"
    />
    <span>
      País seleccionado:
      <pre>{{ selectedCountry }}</pre>
    </span>
  </div>
  <div class="form-field">
    <label>Selección múltiple</label>
    <VSelect
      v-model="selectedCountries"
      :options="countries"
      optionLabel="name"
      optionValue="id"
      multiple
    />
    <span>
      Países seleccionados:
      <pre>{{ selectedCountries.join(", ") }}</pre>
    </span>
  </div>
</template>
Select
Selected country:
Select
Selected countries:

Para lograr esto, realizamos los siguientes pasos:

  • Importar componentes ElementPlus: Importamos ambos componentes ElementPlus y los mostramos según la propiedad multiple.
  • Crear un tipo genérico: Creamos un tipo genérico para TOptionValue, que es un keyof del TOptionType.
  • Definir TReturnType: Luego creamos un TReturnType que devuelve TOptionType o una de sus propiedades.
  • Desarrollar el tipo TSingleOrMultiple: Este TReturnType se utiliza para crear un tipo TSingleOrMultiple basado en la propiedad multiple.
  • Utilizar tipos en Props y Emit: Finalmente, todos estos tipos se usan en las props y funciones emit para proporcionar asistencia adicional.

Conclusión

Usar genéricos y tipos condicionales en Vue 3.3 te permite crear componentes flexibles y reutilizables que se adaptan a diferentes casos de uso sin agregar props o complejidad innecesarios. Este enfoque proporciona varios beneficios:

  • Hace que tu código sea más limpio y mantenible.
  • Mejora la seguridad de tipos, reduciendo errores potenciales en tiempo de ejecución.
  • Mejora la experiencia general del desarrollador al proporcionar un comportamiento más claro y predecible.

Si quieres explorar más sobre Vue 3.3 y genéricos, consulta la documentación oficial o intenta implementar patrones similares en tus propios proyectos.

Extra: ¿Por qué undefined extends T y no T extends undefined?

Cuando quieres realizar una verificación de tipo condicional que involucre undefined, es útil pensar en términos de si T incluye undefined. Esto es lo que verifica undefined extends T: pregunta si undefined es un posible subtipo de T.

Si usaras T extends undefined, solo devolvería true si T mismo es exactamente undefined. Esto no es lo mismo que verificar si undefined podría ser parte de T.

  • undefined extends T: Esto verifica si undefined es parte de los valores posibles de T. En otras palabras, devolverá true si T podría ser undefined (por ejemplo, T es true | false | undefined).
  • T extends undefined: Esto verifica si T es precisamente undefined. Si T tiene otros valores, como true o false, esta condición se evaluaría como false.

Acerca de Jeroen Bach

Soy Ingeniero de Software y Líder de Equipo con más de 15 años de experiencia profesional. Me apasiona resolver problemas complejos a través de soluciones simples y elegantes. Este blog es donde comparto técnicas y perspectivas para construir gran software, inspirado en proyectos del mundo real.

Jeroen Bach

Diseñado en Figma y construido con Vue.js, Nuxt.js y Tailwind CSS. Desplegado vía Azure Static Web App y Azure Functions. Los análisis del sitio web están impulsados por Plausible Analytics, desplegado usando Azure Kubernetes Service.