Conversión de Array a Map en Typescript, con seguridad de tipos

Jeroen Bach

Jeroen Bach · Linkedin

12 min read ·

Aprende a crear una función typescript que convierte arrays en diccionarios con seguridad de tipos y domina los tipos condicionales con inferencia de tipos.

¿Alguna vez has necesitado convertir rápidamente un array en un diccionario de búsqueda? Aunque TypeScript proporciona las herramientas básicas, a menudo te encontrarás escribiendo código de mapeo repetitivo. Aquí está el enfoque estándar:

interface Person { key: number, name: string, birthday: Date }
const data: Person[] = [
  { key: 1, name: 'Rupert', birthday: new Date('1986-04-10')  },
  { key: 2, name: 'Anna', birthday: new Date('1989-10-10') },
];


// Crear un mapa usando el Map predeterminado y Array.map
const dict = new Map(data.map(x => [x.key, x]))
//    ^ const dict: Map<number, Person>

Si bien esto funciona, rápidamente notarás que se siente verboso y repetitivo. ¿Qué tal si pudieras escribir código más intuitivo que sea flexible y con seguridad de tipos? La función toMap a continuación te da exactamente eso, admitiendo tanto nombres de propiedades como funciones personalizadas:

// Crear un mapa usando la propiedad 'key'
const map1 = toMap(data, 'key');
//    ^ const map1: Map<number, Person>

// Crear un mapa usando la función 'key'
const map2 = toMap(data, x => x.key);
//    ^ const map2: Map<number, Person>

// Crear un mapa usando la propiedad 'key' y la propiedad 'value'
const map3 = toMap(data, 'key', 'name');
//    ^ const map3: Map<number, string>

// Crear un mapa usando la función 'key' y la función 'value'
const map4 = toMap(data, x => x.key, x => x.name);
//    ^ const map4: Map<number, string>

// Crear un mapa usando funciones personalizadas
const map5 = toMap(data, x => x.birthday, x => `Name: ${x.name}`);
//    ^ const map5: Map<Date, string>

// Crear un mapa sin parámetros
const map6 = toMap(data);
//    ^ const map6: Map<Person, Person>

¿Listo para construir esta poderosa utilidad? Síguenos mientras la construimos paso a paso. Cada iteración te enseñará nuevos conceptos de TypeScript que puedes aplicar a tus propios proyectos: (haz clic en los bordes del bloque de código para avanzar o retroceder a través de las versiones)

// Comienza simple - construirás complejidad gradualmente
export function toMap<T, K>(
  array: readonly T[],
  keyFn: ((item: T) => K),
) {
  if (!array?.length) return new Map<K, T>();

  return new Map<K, T>(array.map(item => [keyFn(item), item]));
}

const dict = toMap(data, x => x.birthday);
//    ^ const dict: Map<Date, Person>
// Agregamos algo de espaciado para pasar por los pasos sin que las líneas salten

export function toMap<
  T,
  K

>(
  array: readonly T[],
  keyFn: ((item: T) => K),

) {










  if (!array?.length) return new Map<K, T>();










  return new Map<K, T>(array.map(item => [keyFn(item), item]));
}


const dict = toMap(data, x => x.birthday);
//    ^ const dict: Map<Date, Person>
// Ahora aprenderás tipos condicionales - expande el parámetro key para aceptar nombres de propiedades// y mueve el tipo key a genéricos para que puedas referenciarlo más tardeexport function toMap<
  T,
  K,
  KF extends keyof T | ((item: T) => K) >(
  array: readonly T[],
  key: KF, 
) {
  // - Crea tu primer tipo condicional - elige entre tipo de propiedad o tipo de retorno de función  type TKeyType = KF extends keyof T  ? T[KF]                               : KF extends ((item: T) => K)           ? K    : never;                          


  if (!array?.length) return new Map<TKeyType, T>();

  // - Obtendrás un error de TypeScript aquí - Type 'K' is not assignable to type 'TKeyType'  const keyFn: (item: T) => TKeyType    = typeof key === 'function'
      ? key
      : typeof array[0] === 'object' && array[0] !== null
        ? item => item[key as keyof T]
        : item => item; // Fallback cuando el array contiene valores primitivos

  return new Map<TKeyType, T>(array.map(item => [keyFn(item), item]));
}

// - Aún no es la seguridad de tipos que quieresconst dict = toMap(data, x => x.birthday);  //    ^ const dict: Map<unknown, Person>
// Aquí está cómo resolver el error - usa inferencia de tipos en su lugar// - Elimina el parámetro de tipo K y deja que TypeScript lo infieraexport function toMap<
  T,

  KF extends keyof T | ((item: T) => any) >(
  array: readonly T[],
  key: KF, 
) {
  // - Deja que TypeScript infiera el tipo de retorno por ti  type TKeyType = KF extends keyof T
  ? T[KF]
  : KF extends ((item: T) => infer K)     ? K
    : never;



  if (!array?.length) return new Map<TKeyType, T>();


  const keyFn: (item: T) => TKeyType
    = typeof key === 'function'
      ? key
      : typeof array[0] === 'object' && array[0] !== null
        ? item => item[key as keyof T]
        : item => item;

  return new Map<TKeyType, T>(array.map(item => [keyFn(item), item]));
}

// - ¡Perfecto! Ahora obtienes inferencia de tipos adecuadaconst dict = toMap(data, x => x.birthday);  //    ^ const dict: Map<Date, Person>
// Haz la función más flexible - los parámetros opcionales te permiten hacer más
export function toMap<
  T,

  KF extends keyof T | ((item: T) => any) | undefined, >(
  array: readonly T[],
  key?: KF, 
) {
  // - Maneja el caso opcional en tus tipos condicionales, devolviendo T  type TKeyType = undefined extends KF    ? T    : KF extends keyof T
      ? T[KF]
      : KF extends ((item: T) => infer K)
        ? K
        : never;

  if (!array?.length)
    return new Map<TKeyType, T>();

  const keyFn: (item: T) => TKeyType
    = typeof key === 'function'
      ? key
      : key !== undefined && typeof array[0] === 'object' && array[0] !== null        ? item => item[key as keyof T]
        : item => item;

  return new Map<TKeyType, T>(array.map(item => [keyFn(item), item]));
}


const dict = toMap(data);
//    ^ const dict: Map<Person, Person>
// Aplica lo que has aprendido - agrega el parámetro value usando el mismo patrón
export function toMap<
  T,
  KF extends keyof T | ((item: T) => any) | undefined,
  KV extends keyof T | ((item: T) => any) | undefined, >(
  array: readonly T[],
  key?: KF,
  value?: KV, ) {
  type TKeyType = undefined extends KF ? T : KF extends keyof T ? T[KF] : KF extends ((item: T) => infer K) ? K : never;

  type TValueType = undefined extends KV    ? T    : KV extends ((item: T) => infer VF)        ? VF      : KV extends keyof T        ? T[KV]                                   : never;                          
  if (!array?.length)
    return new Map<TKeyType, TValueType>();

  const keyFn: (item: T) => TKeyType
    = typeof key === 'function'
      ? key
      : key !== undefined && typeof array[0] === 'object' && array[0] !== null
        ? item => item[key as keyof T]
        : item => item;

  const valueFn: (item: T) => TValueType    = typeof value === 'function'      ? value                                                     : value !== undefined && typeof array[0] === 'object' && array[0] !== null        ? item => item[value as keyof T]                            : item => item;                                     
  return new Map<TKeyType, TValueType>(array.map(item => [keyFn(item), valueFn(item)]));
}

const dict = toMap(data, 'key', 'birthday');
//    ^ const dict: Map<number, Date>
// Tu resultado final - una utilidad poderosa y con seguridad de tipos que puedes usar en cualquier lugar
/**
 * Ayuda a crear un mapa (diccionario) a partir de una lista de elementos.
 * Para las claves y valores puedes proporcionar un keyof T o una función que devuelva el valor.
 * Si no especificas ningún valor, se usará el elemento completo como valor.
 * @param array el array a convertir
 * @param key un keyof de un elemento del array | una función que devuelve un valor que representa la clave del diccionario
 * @param value un keyof de un elemento del array | una función que devuelve cualquier valor
 * @returns un nuevo Map()
 */
export function toMap<
  T,
  KF extends keyof T | ((item: T) => any) | undefined,
  KV extends keyof T | ((item: T) => any) | undefined,
>(
  array: readonly T[],
  key?: KF,
  value?: KV,
) {
  type TKeyType = undefined extends KF ? T : KF extends keyof T ? T[KF] : KF extends ((item: T) => infer K) ? K : never;
  type TValueType = undefined extends KV ? T : KV extends ((item: T) => infer VF) ? VF : KV extends keyof T ? T[KV] : never;

  if (!array?.length)
    return new Map<TKeyType, TValueType>();

  const keyFn: (item: T) => TKeyType
    = typeof key === 'function'
      ? key
      : key !== undefined && typeof array[0] === 'object' && array[0] !== null
        ? item => item[key as keyof T]
        : item => item;

  const valueFn: (item: T) => TValueType
    = typeof value === 'function'
      ? value
      : value !== undefined && typeof array[0] === 'object' && array[0] !== null
        ? item => item[value as keyof T]
        : item => item;

  // Crear el mapa y recorrer el array solo una vez
  const map = new Map<TKeyType, TValueType>();
  array.forEach((item) => {
    map.set(keyFn(item), valueFn(item));
  });

  return map;
}

const dict = toMap(data, 'key', x => x.birthday);
//    ^ const dict: Map<number, Date>

¿Quieres ver qué tan exhaustivamente se prueba esta función? Consulta el archivo de prueba completo aquí toMap.tests.ts.

Trabajando con strictNullChecks deshabilitado

Cuando strictNullChecks está deshabilitado (o el modo estricto en sí está deshabilitado), undefined se vuelve asignable a todos los tipos, lo que rompe nuestro tipo condicional. En este escenario, independientemente de los parámetros pasados a toMap, el mapa resultante siempre tendrá los tipos de clave y valor T.

Para manejar esta limitación, puedes usar sobrecargas de funciones para proporcionar firmas de tipo explícitas que correspondan a las diferentes combinaciones de parámetros, asegurando la seguridad de tipos incluso cuando las verificaciones estrictas de null están deshabilitadas.

Así es como puedes implementar esto:

toMap.ts
/**
 * Ayuda a crear un mapa (diccionario) a partir de una lista de elementos.
 * Para las claves y valores puedes proporcionar un keyof T o una función que devuelva el valor.
 * Si no especificas ningún valor, se usará el elemento completo como valor.
 *
 * @param array el array a convertir
 * @param key un keyof de un elemento del array | una función que devuelve un valor que representa la clave del diccionario
 * @param value un keyof de un elemento del array | una función que devuelve cualquier valor
 * @returns un nuevo Map()
 */
export function toMap<T>(array: readonly T[]): Map<T, T>;
export function toMap<T, KF extends keyof T>(
  array: readonly T[],
  key: KF,
): Map<T[KF], T>;
export function toMap<T, KF extends (item: T) => any>(
  array: readonly T[],
  key: KF,
): Map<ReturnType<KF>, T>;
export function toMap<T, KF extends keyof T, VF extends keyof T>(
  array: readonly T[],
  key: KF,
  value: VF,
): Map<T[KF], T[VF]>;
export function toMap<T, KF extends keyof T, VF extends (item: T) => any>(
  array: readonly T[],
  key: KF,
  value: VF,
): Map<T[KF], ReturnType<VF>>;
export function toMap<
  T,
  KF extends (item: T) => any,
  VF extends (item: T) => any,
>(
  array: readonly T[],
  key?: KF,
  value?: VF,
): Map<ReturnType<KF>, ReturnType<VF>>;
export function toMap<T, KF extends (item: T) => any, VF extends keyof T>(
  array: readonly T[],
  key: KF,
  value: VF,
): Map<ReturnType<KF>, T[VF]>;

export function toMap<
  T,
  KF extends keyof T | ((item: T) => any) | undefined,
  KV extends keyof T | ((item: T) => any) | undefined,
>(array: readonly T[], key?: KF, value?: KV) {
  type TKeyType = undefined extends KV
    ? T
    : KF extends keyof T
      ? T[KF]
      : KF extends (item: T) => infer K
        ? K
        : never;
  type TValueType = undefined extends KV
    ? T
    : KV extends (item: T) => infer VF
      ? VF
      : KV extends keyof T
        ? T[KV]
        : never;

  if (!array?.length) return new Map<TKeyType, TValueType>();

  const keyFn: (item: T) => TKeyType =
    typeof key === 'function'
      ? key
      : key !== undefined && typeof array[0] === 'object' && array[0] !== null
        ? item => item[key as keyof T]
        : item => item;

  const valueFn: (item: T) => TValueType =
    typeof value === 'function'
      ? value
      : value !== undefined && typeof array[0] === 'object' && array[0] !== null
        ? item => item[value as keyof T]
        : item => item;

  // Crear el mapa y recorrer el array solo una vez
  const map = new Map<TKeyType, TValueType>();
  array?.forEach(item => {
    map.set(keyFn(item), valueFn(item));
  });

  return map;
}

Lo que has logrado

Has transformado una simple línea en una utilidad poderosa y reutilizable. Si bien tu función toMap es más compleja que new Map(data.map(x => [x.key, x])), vale la pena:

  • Mejor legibilidad - Tu intención es clara como el cristal cuando escribes toMap(users, 'id')
  • Seguridad de tipos - La inferencia completa de TypeScript te protege de errores en tiempo de ejecución
  • Flexibilidad - Soporte tanto para nombres de propiedades como funciones personalizadas
  • Reutilización - Una función maneja todas tus necesidades de conversión de array a diccionario

También has aprendido algunos conceptos avanzados de TypeScript que puedes aplicar a otros proyectos: tipos condicionales, inferencia de tipos y construcción de APIs flexibles. Estas habilidades te harán un desarrollador de TypeScript más efectivo.

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.