Capítulo 4. Genéricos

Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com

Hasta ahora, nuestro principal objetivo era tomar la flexibilidad inherente de JavaScript y encontrar una forma de formalizarla mediante el sistema de tipos. Añadimos tipos estáticos para un lenguaje tipado dinámicamente, para comunicar la intención, conseguir herramientas y detectar errores antes de quese produzcan.

Sin embargo, algunas partes de JavaScript no se preocupan realmente de los tipos estáticos. Por ejemplo, una función isKeyAvailableInObject sólo debe comprobar si una clave está disponible en un objeto; no necesita saber nada de los tipos concretos. Para formalizar adecuadamente una función como ésta, podemos utilizar el sistema de tipos estructurales de TypeScript y describir un tipo muy amplio a cambio de información o un tipo muy estricto a cambio deflexibilidad.

Pero no queremos pagar cualquier precio. Queremos tanto flexibilidad como información. Los genéricos en TypeScript son justo la bala de plata que necesitamos. Podemos describir relaciones complejas y formalizar la estructura de los datos que aún no se han definido.

Los genéricos, junto con su pandilla de tipos mapeados, mapas de tipos, modificadores de tipos y tipos ayudantes, abren la puerta al metatipado, donde podemos crear nuevos tipos basados en los antiguos y mantener intactas las relaciones entre tipos, mientras los tipos recién generados cuestionan nuestro código original en busca de posibles errores.

Esta es la entrada a los conceptos avanzados de TypeScript. Pero no temas, no habrá dragones, a menos que los definamos.

4.1 Generalizar las firmas de funciones

Problema

Tienes dos funciones que funcionan igual, pero en tipos distintos y en gran medida incompatibles.

Solución

Generaliza su comportamiento utilizando genéricos.

Debate

Estás escribiendo una aplicación que almacena varios archivos de idioma (por ejemplo, subtítulos) en un objeto. Las claves son los códigos de idioma, y los valores son URLs. Cargas los archivos de idioma seleccionándolos mediante un código de idioma, que procede de alguna API o interfaz de usuario como string. Para asegurarte de que el código de idioma es correcto y válido, añades una función isLanguageAvailable que realiza una comprobación in y establece el tipo correcto mediante un predicado de tipo:

type Languages = {
  de: URL;
  en: URL;
  pt: URL;
  es: URL;
  fr: URL;
  ja: URL;
};

function isLanguageAvailable(
  collection: Languages,
  lang: string
): lang is keyof Languages {
  return lang in collection;
}

function loadLanguage(collection: Languages, lang: string) {
  if (isLanguageAvailable(collection, lang)) {
    // lang is keyof Languages
    collection[lang]; // access ok!
  }
}

La misma aplicación, un escenario diferente, un archivo totalmente distinto. Cargas datos multimedia en un elemento HTML: ya sea audio, vídeo o una combinación con ciertas animaciones en un elemento canvas. Todos los elementos ya existen en la aplicación, pero tienes que seleccionar el correcto basándote en la entrada de una API. De nuevo, la selección viene como string, y escribes una función isElementAllowed para asegurarte de que la entrada es realmente una clave válida de tu colección AllowedElements:

type AllowedElements = {
  video: HTMLVideoElement;
  audio: HTMLAudioElement;
  canvas: HTMLCanvasElement;
};

function isElementAllowed(
  collection: AllowedElements,
  elem: string
): elem is keyof AllowedElements {
  return elem in collection;
}

function selectElement(collection: AllowedElements, elem: string) {
  if (isElementAllowed(collection, elem)) {
    // elem is keyof AllowedElements
    collection[elem]; // access ok
  }
}

No necesitas fijarte demasiado para ver que ambos escenarios son muy similares. Llaman especialmente la atención las funciones de protección de tipo. Si eliminamos toda la información de tipo y alineamos los nombres, son idénticos:

function isAvailable(obj, key) {
  return key in obj;
}

Ambos existen gracias a la información de tipo que obtenemos. No por los parámetros de entrada, sino por los predicados de tipo. En ambos casos, podemos saber más sobre los parámetros de entrada afirmando un tipo específico keyof.

El problema es que los dos tipos de entrada de la colección son totalmente distintos y no se solapan. Excepto en el caso del objeto vacío, para el que no obtenemos tanta información valiosa si creamos un tipo keyof. keyof {} es en realidad never.

Pero aquí hay alguna información de tipo que podemos generalizar. Sabemos que el primer parámetro de entrada es un objeto. Y el segundo es una clave de propiedad. Si esta comprobación evalúa a true, sabemos que el primer parámetro es una clave del segundo parámetro.

Para generalizar esta función, podemos añadir un parámetro de tipo genérico a isAvailable llamado Obj, entre paréntesis angulares. Se trata de un marcador de posición para un tipo real que se sustituirá una vez que se utilice isAvailable. Podemos utilizar este parámetro de tipo genérico como utilizaríamos AllowedElements o Languages y podemos añadir un predicado de tipo. Como Obj puede ser sustituido por cualquier tipo, key tiene que incluir todas las claves de propiedad posibles:string, symbol y number:

function isAvailable<Obj>(
  obj: Obj,
  key: string | number | symbol
): key is keyof Obj {
  return key in obj;
}

function loadLanguage(collection: Languages, lang: string) {
  if (isAvailable(collection, lang)) {
    // lang is keyof Languages
    collection[lang]; // access ok!
  }
}

function selectElement(collection: AllowedElements, elem: string) {
  if (isAvailable(collection, elem)) {
    // elem is keyof AllowedElements
    collection[elem]; // access ok
  }
}

Y ahí lo tienes: una función que funciona en ambos escenarios, independientemente de los tipos que sustituyamos por Obj. ¡Igual que funciona JavaScript! Seguimos obteniendo la misma funcionalidad, y obtenemos la información de tipo correcta. El acceso al índice se vuelve seguro, sin sacrificar la flexibilidad.

¿Y lo mejor? Podemos utilizar isAvailable igual que utilizaríamos un equivalente JavaScript no tipado. Esto se debe a que TypeScript infiere los tipos de los parámetros de tipo genérico a través del uso. Y esto tiene algunos efectos secundarios. Puedes leer más sobre esto en la Receta 4.3.

4.3 Deshacerse de cualquier y desconocido

Problema

Los parámetros de tipo genérico, any y unknown parecen describir conjuntos de valores muy amplios. ¿Cuándo debes utilizar qué?

Solución

Utiliza parámetros de tipo genérico cuando llegues finalmente al tipo real; consulta la Receta 2.2 sobre la decisión entre any y unknown.

Debate

Cuando utilizamos genéricos, pueden parecer un sustituto de any y unknown. Tomemos una función identity: su única función es devolver el valor pasado comoparámetro de entrada:

function identity(value: any): any {
  return value;
}

let a = identity("Hello!");
let b = identity(false);
let c = identity(2);

Toma valores de cualquier tipo, y su tipo de retorno también puede ser cualquiera. Podemos escribir la misma función utilizando unknown si queremos acceder de forma segura a las propiedades:

function identity(value: unknown): unknown {
  return value;
}

let a = identity("Hello!");
let b = identity(false);
let c = identity(2);

Podemos incluso mezclar y combinar any y unknown, pero el resultado es siempre el mismo: se pierde la información de tipo. El tipo del valor de retorno es el que nosotros definamos.

Ahora escribamos la misma función con genéricos en lugar de any o unknown. Sus anotaciones de tipo dicen que el tipo genérico es también el tipo de retorno:

function identity<T>(t: T): T {
  return t;
}

Podemos utilizar esta función para pasar cualquier valor y ver qué tipo infiere TypeScript:

let a = identity("Hello!"); // a is string
let b = identity(2000);     // b is number
let c = identity({ a: 2 }); // c is { a: number }

La asignación a un enlace con const en lugar de let da resultados ligeramente diferentes:

const a = identity("Hello!"); // a is "Hello!"
const b = identity(2000);     // b is 2000
const c = identity({ a: 2 }); // c is { a: number }

Para los tipos primitivos, TypeScript sustituye el parámetro de tipo genérico por el tipo real. Podemos hacer un gran uso de esto en escenarios más avanzados.

Con los genéricos de TypeScript, también es posible anotar el parámetro de tipo genérico:

const a = identity<string>("Hello!"); // a is string
const b = identity<number>(2000);     // b is number
const c = identity<{ a: 2 }>({ a: 2 }); // c is { a: 2 }

Si este comportamiento te recuerda a la anotación e inferencia descritas en la Receta 3.4, tienes toda la razón. Es muy parecido, pero con parámetros de tipo genérico en las funciones.

Al utilizar genéricos sin restricciones, podemos escribir funciones que funcionen con valores de cualquier tipo. En su interior, se comportan como unknown, lo que significa que podemos hacer guardias de tipo para acotar el tipo. La mayor diferencia es que, una vez que utilizamos la función, sustituimos nuestros genéricos por tipos reales, sin perder en absoluto información sobre la tipificación.

Esto nos permite ser un poco más claros con nuestros tipos que limitándonos a permitirlo todo. Esta función pairs toma dos argumentos y crea una tupla:

function pairs(a: unknown, b: unknown): [unknown, unknown] {
  return [a, b];
}

const a = pairs(1, "1"); // [unknown, unknown]

Con parámetros de tipo genérico, obtenemos un bonito tipo tupla:

function pairs<T, U>(a: T, b: U): [T, U] {
  return [a, b];
}

const b = pairs(1, "1"); // [number, string]

Utilizando el mismo parámetro de tipo genérico, podemos asegurarnos de que sólo obtenemos tuplas en las que cada elemento es del mismo tipo:

function pairs<T>(a: T, b: T): [T, T] {
  return [a, b];
}

const c = pairs(1, "1");
//                  ^
// Argument of type 'string' is not assignable to parameter of type 'number'

Entonces, ¿debes utilizar genéricos en todas partes? No necesariamente. Este capítulo incluye muchas soluciones que dependen de obtener la información de tipo correcta en el momento adecuado. Cuando te conformes con un conjunto más amplio de valores y puedas confiar en que los subtipos son compatibles, no necesitas utilizar genéricos en absoluto. Si tienes any y unknown en tu código, piensa si necesitas el tipo real en algún momento. Añadir un parámetro de tipo genérico en su lugar podría ayudarte.

4.4 Comprender la instanciación genérica

Problema

Entiendes cómo se sustituyen los tipos reales por genéricos, pero a veces errores como "Foo es asignable a la restricción de tipo Bar, pero podría instanciarse con un subtipo diferente de la restricción Baz" te confunden.

Solución

Recuerda que los valores de un tipo genérico pueden ser sustituidos -explícita e implícitamente- por diversos subtipos. Escribe código compatible con los subtipos.

Debate

Creas una lógica de filtro para tu aplicación. Tienes diferentes reglas de filtro que puedes combinar utilizando los combinadores de "and" | "or". También puedes encadenar reglas de filtro regulares con el resultado de filtros combinatorios. Creas tus tipos basándote en estecomportamiento:

type FilterRule = {
  field: string;
  operator: string;
  value: any;
};

type CombinatorialFilter = {
  combinator: "and" | "or";
  rules: FilterRule[];
};

type ChainedFilter = {
  rules: (CombinatorialFilter | FilterRule)[];
};

type Filter = CombinatorialFilter | ChainedFilter;

Ahora quieres escribir una función reset que, basándose en un filtro ya proporcionado, restablezca todas las reglas. Utiliza guardas de tipo para distinguir entre CombinatorialFilter y ChainedFilter:

function reset(filter: Filter): Filter {
  if ("combinator" in filter) {
    // filter is CombinatorialFilter
    return { combinator: "and", rules: [] };
  }
  // filter is ChainedFilter
  return { rules: [] };
}

const filter: CombinatorialFilter = { rules: [], combinator: "or" };
const resetFilter = reset(filter); // resetFilter is Filter

El comportamiento es el que buscas, pero el tipo de retorno de reset es demasiado amplio. Cuando pasamos un CombinatorialFilter, debemos asegurarnos de que el filtro de retorno es también un Co⁠mb⁠in⁠ato⁠rial​Fil⁠ter. Aquí es del tipo unión, tal como indica la firma de nuestra función. Pero quieres asegurarte de que si pasas un filtro de un tipo determinado, también obtienes el mismo tipo de retorno. Así que sustituyes el tipo de unión amplio por un parámetro de tipo genérico que está restringido a Filter. El tipo de retorno funciona según lo previsto, pero la implementación de tu función arroja errores:

function reset<F extends Filter>(filter: F): F {
  if ("combinator" in filter) {
    return { combinator: "and", rules: [] };
//  ^ '{ combinator: "and"; rules: never[]; }' is assignable to
//     the constraint of type 'F', but 'F' could be instantiated
//     with a different subtype of constraint 'Filter'.
  }
  return { rules: [] };
//^ '{ rules: never[]; }' is assignable to the constraint of type 'F',
//   but 'F' could be instantiated with a different subtype of
//   constraint 'Filter'.
}

const resetFilter = reset(filter); // resetFilter is CombinatorialFilter

Mientras que tú quieres diferenciar entre dos partes de una unión, TypeScript piensa de forma más amplia. Sabe que puedes pasar un objeto estructuralmente compatible con Filter, pero que tiene más propiedades y, por tanto, es un subtipo.

Esto significa que puedes llamar a reset con F instanciado a un subtipo, y tu programa anularía alegremente todas las propiedades sobrantes. Esto es incorrecto, y TypeScript te lo dice:

const onDemandFilter = reset({
  combinator: "and",
  rules: [],
  evaluated: true,
  result: false,
});
/* filter is {
    combinator: "and";
    rules: never[];
    evaluated: boolean;
    result: boolean;
}; */

Supera esto escribiendo código amigable con los subtipos. Clona el objeto de entrada (que siga siendo del tipo F), establece las propiedades que deban modificarse en consecuencia y devuelve algo que siga siendo del tipo F:

function reset<F extends Filter>(filter: F): F {
  const result = { ...filter }; // result is F
  result.rules = [];
  if ("combinator" in result) {
    result.combinator = "and";
  }
  return result;
}

const resetFilter = reset(filter); // resetFilter is CombinatorialFilter

Los tipos genéricos pueden ser uno de muchos en una unión, pero pueden ser mucho, mucho más. El sistema de tipos estructurales de TypeScript te permite trabajar con una gran variedad de subtipos, y tu código debe reflejarlo.

He aquí un escenario diferente pero con un resultado similar. Quieres crear una estructura de datos en forma de árbol y escribir un tipo recursivo que almacene todos los elementos del árbol. Este tipo puede sersubtipado, así que escribes una función createRootItem con un parámetro de tipo genérico, ya que quieres instanciarlo con el subtipo correcto:

type TreeItem = {
  id: string;
  children: TreeItem[];
  collapsed?: boolean;
};

function createRootItem<T extends TreeItem>(): T {
  return {
    id: "root",
    children: [],
  };
// '{ id: string; children: never[]; }' is assignable to the constraint
//   of type 'T', but 'T' could be instantiated with a different subtype
//   of constraint 'TreeItem'.(2322)
}

const root = createRootItem(); // root is TreeItem

Obtenemos un error similar al anterior, ya que no podemos afirmar que el valor de retorno será compatible con todos los subtipos. Para resolver este problema, ¡deshazte del genérico! Sabemos cómo será el tipo de retorno: es un TreeItem:

function createRootItem(): TreeItem {
  return {
    id: "root",
    children: [],
  };
}

Las soluciones más sencillas suelen ser las mejores. Pero ahora quieres ampliar tu programa pudiendo adjuntar hijos de tipo o subtipo TreeItem a una raíz recién creada. Aún no hemos añadido ningún genérico y estamos algo insatisfechos:

function attachToRoot(children: TreeItem[]): TreeItem {
  return {
    id: "root",
    children,
  };
}

const root = attachToRoot([]); // TreeItem

root es del tipo TreeItem, pero perdemos toda información sobre los hijos subtipados. Incluso si añadimos un parámetro de tipo genérico sólo para los hijos, restringido a Tr⁠ee​It⁠em, no conservamos esta información sobre la marcha:

function attachToRoot<T extends TreeItem>(children: T[]): TreeItem {
  return {
    id: "root",
    children,
  };
}

const root = attachToRoot([
  {
    id: "child",
    children: [],
    collapsed: false,
    marked: true,
  },
]); // root is TreeItem

Cuando empezamos a añadir un tipo genérico como tipo de retorno, nos encontramos con los mismos problemas que antes. Para resolver este problema, tenemos que separar el tipo de elemento raíz del tipo de elemento hijo, abriendo TreeItem para que sea un genérico, donde podemos establecer que Children sea un subtipo de TreeItem.

Como queremos evitar cualquier referencia circular, tenemos que establecer Children en un valor por defecto BaseTreeItem, de modo que podamos utilizar TreeItem tanto como restricción para Children como para attachToRoot:

type BaseTreeItem = {
  id: string;
  children: BaseTreeItem[];
};

type TreeItem<Children extends TreeItem = BaseTreeItem> = {
  id: string;
  children: Children[];
  collapsed?: boolean;
};

function attachToRoot<T extends TreeItem>(children: T[]): TreeItem<T> {
  return {
    id: "root",
    children,
  };
}

const root = attachToRoot([
  {
    id: "child",
    children: [],
    collapsed: false,
    marked: true,
  },
]);
/*
root is TreeItem<{
    id: string;
    children: never[];
    collapsed: false;
    marked: boolean;
}>
*/

De nuevo, escribimos subtipos amigables y tratamos nuestros parámetros de entrada como propios, en lugar de hacer suposiciones.

4.5 Generar nuevos tipos de objetos

Problema

Tienes un tipo en tu aplicación que está relacionado con tu modelo. Cada vez que cambia el modelo, tienes que cambiar también tus tipos.

Solución

Utiliza tipos genéricos mapeados para crear nuevos tipos de objeto basados en el tipo original.

Debate

Volvamos a la juguetería de la Receta 3.1. Gracias a los tipos de unión, los tipos de intersección y los tipos de unión discriminados, pudimos modelar nuestros datos bastante bien:

type ToyBase = {
  name: string;
  description: string;
  minimumAge: number;
};

type BoardGame = ToyBase & {
  kind: "boardgame";
  players: number;
};

type Puzzle = ToyBase & {
  kind: "puzzle";
  pieces: number;
};

type Doll = ToyBase & {
  kind: "doll";
  material: "plush" | "plastic";
};

type Toy = Doll | Puzzle | BoardGame;

En algún lugar de nuestro código, necesitamos agrupar todos los juguetes de nuestro modelo en una estructura de datos que puede describirse mediante un tipo llamado GroupedToys. GroupedToys tiene una propiedad para cada categoría (o "kind") y una matriz Toy como valor. Una función groupToys toma una lista sin ordenar de juguetes y los agrupa por tipo:

type GroupedToys = {
  boardgame: Toy[];
  puzzle: Toy[];
  doll: Toy[];
};

function groupToys(toys: Toy[]): GroupedToys {
  const groups: GroupedToys = {
    boardgame: [],
    puzzle: [],
    doll: [],
  };
  for (let toy of toys) {
    groups[toy.kind].push(toy);
  }
  return groups;
}

Ya hay algunas sutilezas en este código. En primer lugar, utilizamos una anotación de tipo explícita al declarar groups. Esto garantiza que no olvidamos ninguna categoría. Además, como las claves de GroupedToys son las mismas que la unión de los tipos de "kind" en Toy, podemos indexar fácilmente el acceso a groups mediante toy.kind.

Pasan los meses y los sprints, y necesitamos volver a tocar nuestro modelo. La juguetería vende ahora ladrillos de juguete encajables originales o quizá de otros proveedores. Conectamos el nuevo tipo Bricks a nuestro modelo Toy:

type Bricks = ToyBase & {
  kind: "bricks",
  pieces: number;
  brand: string;
}

type Toy = Doll | Puzzle | BoardGame | Bricks;

Como groupToys también tiene que tratar con Bricks, obtenemos un bonito error porque GroupedToys no tiene ni idea de un tipo "bricks":

function groupToys(toys: Toy[]): GroupedToys {
  const groups: GroupedToys = {
    boardgame: [],
    puzzle: [],
    doll: [],
  };
  for (let toy of toys) {
    groups[toy.kind].push(toy);
//  ^- Element implicitly has an 'any' type because expression
//     of type '"boardgame" | "puzzle" | "doll" | "bricks"' can't
//     be used to index type 'GroupedToys'.
//     Property 'bricks' does not exist on type 'GroupedToys'.(7053)
  }
  return groups;
}

Este es un comportamiento deseado en TypeScript: saber cuándo los tipos ya no coinciden. Esto debería llamar nuestra atención. Vamos a actualizar GroupedToys y groupToys:

type GroupedToys = {
  boardgame: Toy[];
  puzzle: Toy[];
  doll: Toy[];
  bricks: Toy[];
};

function groupToys(toys: Toy[]): GroupedToys {
  const groups: GroupedToys = {
    boardgame: [],
    puzzle: [],
    doll: [],
    bricks: [],
  };
  for (let toy of toys) {
    groups[toy.kind].push(toy);
  }
  return groups;
}

Hay una cosa molesta: la tarea de agrupar juguetes es siempre la misma. Por mucho que cambie nuestro modelo, siempre seleccionaremos por tipo y empujaremos a un array. Tendríamos que actualizar groups con cada cambio, pero si cambiamos la forma de pensar sobre los grupos, podemos optimizar el cambio. En primer lugar, cambiamos el tipo Gr⁠oup⁠ed​To⁠ys para que incluya propiedades opcionales. En segundo lugar, inicializamos cada grupo con una matriz vacía si aún no se ha producido ninguna inicialización:

type GroupedToys = {
  boardgame?: Toy[];
  puzzle?: Toy[];
  doll?: Toy[];
  bricks?: Toy[];
};


function groupToys(toys: Toy[]): GroupedToys {
  const groups: GroupedToys = {};
  for (let toy of toys) {
    // Initialize when not available
    groups[toy.kind] = groups[toy.kind] ?? [];
    groups[toy.kind]?.push(toy);
  }
  return groups;
}

Ya no necesitamos mantener groupToys. Lo único que necesita mantenimiento es el tipo GroupedToys. Si observamos detenidamente GroupedToys, veremos que existe una relación implícita con Toy. Cada clave de propiedad forma parte de Toy["kind"]. Hagamos explícita esta relación. Con un tipo mapeado, creamos un nuevo tipo de objeto basado en cada tipo de Toy["kind"].

Toy["kind"] es una unión de literales de cadena: "boardgame" | "puzzle" | "doll" | "bricks". Como tenemos un conjunto muy reducido de cadenas, cada elemento de esta unión se utilizará como su propia clave de propiedad. Deja que eso se asimile por un momento: podemos utilizar un tipo para que sea una clave de propiedad de un tipo recién generado. Cada propiedad tiene un modificador de tipo opcional y apunta a un Toy[]:

type GroupedToys = {
  [k in Toy["kind"]]?: Toy[];
};

¡Fantástico! Cada vez que cambiamos Toy, cambiamos inmediatamente Toy[]. Nuestro código no necesita ningún cambio; podemos seguir agrupando por tipo como antes.

Éste es un patrón que podemos generalizar. Vamos a crear un tipo Group que tome una colección y la agrupe mediante un selector específico. Queremos crear un tipo genérico con dos parámetros de tipo:

  • El Collection puede ser cualquier cosa.

  • El Selector, una clave de Collection, para que pueda crear las propiedades respectivas.

Nuestro primer intento sería tomar lo que teníamos en GroupedToys y sustituir los tipos concretos por parámetros de tipo. Esto crea lo que necesitamos, pero también provoca un error:

// How to use it
type GroupedToys = Group<Toy, "kind">;

type Group<Collection, Selector extends keyof Collection> = {
  [x in Collection[Selector]]?: Collection[];
//     ^ Type 'Collection[Selector]' is not assignable
//       to type 'string | number | symbol'.
//       Type 'Collection[keyof Collection]' is not
//       assignable to type 'string | number | symbol'.
//       Type 'Collection[string] | Collection[number]
//        | Collection[symbol]' is not assignable to
//       type 'string | number | symbol'.
//       Type 'Collection[string]' is not assignable to
//       type 'string | number | symbol'.(2322)
};

TypeScript nos advierte de que Collection[string] | Collection[number] | Collection[symbol] puede dar lugar a cualquier cosa, no sólo a cosas que puedan utilizarse como clave. Eso es cierto, y tenemos que prepararnos para ello. Tenemos dos opciones.

Primero, utiliza una restricción de tipo en Collection que apunte a Record<string, any>. Record es un tipo de utilidad que genera un nuevo objeto en el que el primer parámetro te da todas las claves y el segundo parámetro te da los tipos:

// This type is built-in!
type Record<K extends string | number | symbol, T> = { [P in K]: T; };

Esto eleva Collection a un objeto comodín, desactivando efectivamente la comprobación de tipo de Groups. Esto está bien porque si algo fuera un tipo inutilizable para una clave de propiedad, TypeScript lo desechará de todos modos. Así que el Group final tiene dos parámetros de tipo restringido:

type Group<
  Collection extends Record<string, any>,
  Selector extends keyof Collection
> = {
  [x in Collection[Selector]]: Collection[];
};

La segunda opción es hacer una comprobación por cada clave para ver si es una clave de cadena válida. Podemos utilizar un tipo condicional para ver si Collection[Selector] es de hecho un tipo válido para una clave. En caso contrario, eliminaríamos este tipo eligiendo never. Los tipos condicionales son su propia bestia, y lo abordamos ampliamente en la Receta 5.4:

type Group<Collection, Selector extends keyof Collection> = {
  [k in Collection[Selector] extends string
    ? Collection[Selector]
    : never]?: Collection[];
};

Observa que hemos eliminado el modificador de tipo opcional. Lo hacemos porque hacer que las claves sean opcionales no es tarea de la agrupación. Para eso tenemos otro tipo Partial<T> otro tipo mapeado que hace que todas las propiedades de un tipo de objeto sean opcionales:

// This type is built-in!
type Partial<T> = { [P in keyof T]?: T[P] };

Independientemente del ayudante Group que crees, ahora puedes crear un objeto GroupedToys diciéndole a TypeScript que quieres un Partial (cambiando todo a propiedades opcionales) de un Group de Toys por "kind":

type GroupedToys = Partial<Group<Toy, "kind">>;

Eso sí que se lee bien.

4.6 Modificar objetos con firmas de aserción

Problema

Tras la ejecución de una determinada función en tu código, sabes que el tipo de un valor ha cambiado.

Solución

Utiliza firmas de aserción para cambiar de tipo independientemente de las declaraciones if y switch.

Debate

JavaScript es un lenguaje muy flexible. Sus características de tipado dinámico te permiten cambiar objetos en tiempo de ejecución, añadiendo nuevas propiedades sobre la marcha. Y los desarrolladores utilizan esto. Hay situaciones en las que, por ejemplo, pasas por encima de una colección de elementos y necesitas afirmar ciertas propiedades. Entonces almacenas una propiedad checked y la pones a true, sólo para saber que has superado una determinada marca:

function check(person: any) {
  person.checked = true;
}

const person = {
  name: "Stefan",
  age: 27,
};

check(person); // person now has the checked property

person.checked; // this is true!

Quieres reflejar este comportamiento en el sistema de tipos; de lo contrario, tendrías que hacer constantemente comprobaciones adicionales si determinadas propiedades están en un objeto, aunque puedas estar seguro de que existen.

Una forma de afirmar que existen determinadas propiedades son, bueno, las afirmaciones de tipo. Decimos que, en un momento determinado, esta propiedad tiene un tipo diferente:

(person as typeof person & { checked: boolean }).checked = true;

Bien, pero tendrías que hacer esta afirmación de tipo una y otra vez, ya que no cambian el tipo original de person. Otra forma de afirmar que ciertas propiedades están disponibles es crear predicados de tipo, como los que se muestran en la Receta 3.5:

function check<T>(obj: T): obj is T & { checked: true } {
  (obj as T & { checked: boolean }).checked = true;
  return true;
}

const person = {
  name: "Stefan",
  age: 27,
};

if (check(person)) {
  person.checked; // checked is true!
}

Sin embargo, esta situación es un poco diferente, lo que hace que la función check parezca torpe: tienes que hacer una condición adicional y devolver true en la función de predicado. Esto no parece correcto.

Afortunadamente, TypeScript tiene otra técnica que podemos aprovechar en situaciones como ésta: las firmas de aserción. Las firmas de aserción pueden cambiar el tipo de un valor en el flujo de control, sin necesidad de condicionales. Se han modelado para la función assert de Node.js, que toma una condición y lanza un error si no es verdadera. Esto significa que, después de llamar a assert, puedes tener más información que antes. Por ejemplo, si llamas a assert y compruebas si un valor tiene el tipo string, sabrás que después de esta función assert el valor debería ser string:

function assert(condition: any, msg?: string): asserts condition {
  if (!condition) {
    throw new Error(msg);
  }
}

function yell(str: any) {
  assert(typeof str === "string");
  // str is string
  return str.toUpperCase();
}

Ten en cuenta que la función se cortocircuita si la condición es falsa. Lanza un error, el caso never. Si esta función pasa, puedes afirmar realmente la condición.

Aunque las firmas de aserción se han modelado para la función de aserción de Node.js, puedes asertar cualquier tipo que desees. Por ejemplo, puedes tener una función que tome cualquier valor para una suma, pero que asevere que los valores deben ser number para continuar:

function assertNumber(val: any): asserts val is number {
  if (typeof val !== "number") {
    throw Error("value is not a number");
  }
}

function add(x: unknown, y: unknown): number {
  assertNumber(x); // x is number
  assertNumber(y); // y is number
  return x + y;
}

Todos los ejemplos que encuentres sobre firmas de aserción se basan en aserciones y cortocircuitos con errores. Pero podemos utilizar la misma técnica para decirle a TypeScript que hay más propiedades disponibles. Escribimos una función muy similar a check en la función de predicado anterior, pero esta vez no necesitamos devolver true. Establecemos la propiedad, y como en JavaScript los objetos se pasan por valor, podemos afirmar que después de llamar a esta función cualquier cosa que pasemos tiene una propiedad checked, que es true:

function check<T>(obj: T): asserts obj is T & { checked: true } {
  (obj as T & { checked: boolean }).checked = true;
}

const person = {
  name: "Stefan",
  age: 27,
};

check(person);

Y con eso, podemos modificar el tipo de un valor sobre la marcha. Es una técnica poco conocida que puede ayudarte mucho.

4.7 Asignar tipos con mapas de tipos

Problema

Escribes una función de fábrica que crea un objeto de un subtipo concreto a partir de un identificador de cadena, y hay un montón de subtipos posibles.

Solución

Almacena todos los subtipos en un mapa de tipos, amplíalos con acceso a índices y utiliza tipos mapeados como Partial<T>.

Debate

Las funciones de fábrica son estupendas si quieres crear variantes de objetos complejos a partir de cierta información básica. Un escenario que quizá conozcas del JavaScript del navegador es la creación de elementos. La función document.createElement acepta el nombre de etiqueta de un elemento, y obtienes un objeto en el que puedes modificar todas laspropiedades necesarias.

Quieres aderezar esta creación con una bonita función de fábrica que llamas cr⁠ea⁠te​El⁠eme⁠nt. No sólo toma el nombre de la etiqueta del elemento, sino que también crea una lista de propiedades para que no tengas que establecer cada propiedad individualmente:

// Using create Element

// a is HTMLAnchorElement
const a = createElement("a", { href: "https://fettblog.eu" });
// b is HTMLVideoElement
const b = createElement("video", { src: "/movie.mp4", autoplay: true });
// c is HTMLElement
const c = createElement("my-element");

Si quieres crear buenos tipos para ello, debes tener en cuenta dos cosas:

  • Asegúrate de crear sólo elementos HTML válidos.

  • Proporciona un tipo que acepte un subconjunto de propiedades de un elemento HTML.

Ocupémonos primero de los elementos HTML válidos. Hay unos 140 elementos HTML posibles, que son muchos. Cada uno de esos elementos tiene un nombre de etiqueta, que puede representarse como una cadena, y un objeto prototipo respectivo en el DOM. Utilizando la lib dom en tu tsconfig.json, TypeScript tiene información sobre esos objetos prototipo en forma de tipos. Y puedes averiguar los 140 nombres de elementos.

Una buena forma de proporcionar una correspondencia entre los nombres de las etiquetas de los elementos y los objetos prototipo es utilizar un mapa de tipos. Un mapa de tipos es una técnica en la que tomas un alias de tipo o interfaz y dejas que las claves apunten a las respectivas variantes de tipo. Luego puedes obtener la variante de tipo correcta utilizando el acceso al índice de un tipo literal de cadena:

type AllElements = {
  a: HTMLAnchorElement;
  div: HTMLDivElement;
  video: HTMLVideoElement;
  //... and ~140 more!
};

// HTMLAnchorElement
type A = AllElements["a"];

Se parece al acceso a las propiedades de un objeto JavaScript mediante el acceso a índices, pero recuerda que seguimos trabajando a nivel de tipo. Esto significa que el acceso al índice puede ser amplio:

type AllElements = {
  a: HTMLAnchorElement;
  div: HTMLDivElement;
  video: HTMLVideoElement;
  //... and ~140 more!
};

// HTMLAnchorElement | HTMLDivELement
type AandDiv = AllElements["a" | "div"];

Utilicemos este mapa para escribir la función createElement. Utilizamos un parámetro de tipo genérico restringido a todas las claves de AllElements, lo que nos permite pasar sólo elementos HTML válidos:

function createElement<T extends keyof AllElements>(tag: T): AllElements[T] {
  return document.createElement(tag as string) as AllElements[T];
}

// a is HTMLAnchorElement
const a = createElement("a");

Utiliza aquí los genéricos para fijar un literal de cadena a un tipo literal, que podemos utilizar para indexar la variante correcta del elemento HTML desde el mapa de tipos. Ten en cuenta también que utilizar do⁠cum⁠ent.​cre⁠ate⁠Ele⁠me⁠nt requiere dos afirmaciones de tipo. Una amplía el conjunto (T a string), y la otra lo reduce (HTMLElement a AllElements[T]). Ambas aserciones indican que tenemos que tratar con una API fuera de nuestro control, como se establece en la Receta 3.9. Nos ocuparemos de las afirmaciones más adelante.

Ahora queremos proporcionar la opción de pasar propiedades extra para dichos elementos HTML, para establecer un href en un HTMLAnchorElement, etc. Todas las propiedades ya están en las respectivas variantes de HTMLElement, pero son obligatorias, no opcionales. Podemos hacer que todas las propiedades sean opcionales con el tipo incorporado Partial<T>. Es un tipo mapeado que toma todas las propiedades de un determinado tipo y les añade un modificador de tipo:

type Partial<T> = { [P in keyof T]?: T[P] };

Ampliamos nuestra función con un argumento opcional props que es un Partial del elemento indexado de AllElements. De este modo, sabemos que si pasamos un "a", sólo podremos establecer propiedades que estén disponibles en HTMLAnchorElement:

function createElement<T extends keyof AllElements>(
  tag: T,
  props?: Partial<AllElements[T]>
): AllElements[T] {
  const elem = document.createElement(tag as string) as AllElements[T];
  return Object.assign(elem, props);
}

const a = createElement("a", { href: "https://fettblog.eu" });
const x = createElement("a", { src: "https://fettblog.eu" });
//                           ^--
// Argument of type '{ src: string; }' is not assignable to parameter
// of type 'Partial<HTMLAnchorElement>'.
// Object literal may only specify known properties, and 'src' does not
// exist in type 'Partial<HTMLAnchorElement>'.(2345)

¡Fantástico! Ahora te toca a ti descubrir los 140 elementos HTML. O no. Alguien ya hizo el trabajo y puso HTMLElementTagNameMap en lib.dom.ts. Así que utilicemos esto en su lugar:

function createElement<T extends keyof HTMLElementTagNameMap>(
  tag: T,
  props?: Partial<HTMLElementTagNameMap[T]>
): HTMLElementTagNameMap[T] {
  const elem = document.createElement(tag);
  return Object.assign(elem, props);
}

Ésta es también la interfaz que utiliza document.createElement, por lo que no hay fricción entre tu función de fábrica y la incorporada. No son necesarias aserciones adicionales.

Sólo hay una advertencia. Estás restringido a los 140 elementos proporcionados por HT⁠ML​Ele⁠men⁠tTa⁠gNa⁠me⁠Map. ¿Y si quieres crear elementos SVG, o componentes web que puedan tener nombres de elementos totalmente personalizados? De repente, tu función de fábrica estádemasiado limitada.

Para permitir más -como hace document.createElement - tendríamos que volver a añadir todas las cadenas posibles a la mezcla. HTMLElementTagNameMap es una interfaz. Así que podemos utilizar la fusión de declaraciones para ampliar la interfaz con una firma indexada, en la que asignemos todas las cadenas restantes a HTMLUnknownElement:

interface HTMLElementTagNameMap {
  [x: string]: HTMLUnknownElement;
};

function createElement<T extends keyof HTMLElementTagNameMap>(
  tag: T,
  props?: Partial<HTMLElementTagNameMap[T]>
): HTMLElementTagNameMap[T] {
  const elem = document.createElement(tag);
  return Object.assign(elem, props);
}

// a is HTMLAnchorElement
const a = createElement("a", { href: "https://fettblog.eu" });
// b is HTMLUnknownElement
const b = createElement("my-element");

Ahora tenemos todo lo que queremos:

  • Una gran función de fábrica para crear elementos HTML tipados

  • Posibilidad de establecer las propiedades de los elementos con un solo objeto de configuración

  • La flexibilidad para crear más elementos de los definidos

Esto último está muy bien, pero ¿y si sólo quieres permitir componentes web? Los componentes web tienen una convención: deben tener un guión en su nombre de etiqueta. Podemos modelar esto utilizando un tipo mapeado en un tipo literal de plantilla de cadena. Aprenderás todo sobre los tipos literales de plantilla de cadena en el Capítulo 6.

Por ahora, lo único que necesitas saber es que creamos un conjunto de cadenas donde el patrón es cualquier cadena seguida de un guión seguido de cualquier cadena. Esto es suficiente para asegurarnos de que sólo pasamos nombres de elementos correctos.

Los tipos mapeados sólo funcionan con alias de tipo, no con declaraciones de interfaz, por lo que tenemos que volver a definir un tipo AllElements:

type AllElements = HTMLElementTagNameMap &
  {
    [x in `${string}-${string}`]: HTMLElement;
  };

function createElement<T extends keyof AllElements>(
  tag: T,
  props?: Partial<AllElements[T]>
): AllElements[T] {
  const elem = document.createElement(tag as string) as AllElements[T];
  return Object.assign(elem, props);
}

const a = createElement("a", { href: "https://fettblog.eu" }); // OK
const b = createElement("my-element"); // OK


const c = createElement("thisWillError");
//                      ^
// Argument of type '"thisWillError"' is not
// assignable to parameter of type '`${string}-${string}`
// | keyof HTMLElementTagNameMap'.(2345)

Fantástico. Con el tipo AllElements también recuperamos las aserciones de tipo, que no nos gustan tanto. En ese caso, en lugar de aserciones, también podemos utilizar una sobrecarga de función, definiendo dos declaraciones: una para nuestros usuarios y otra para que implementemos la función. Puedes aprender más sobre esta técnica de sobrecarga de funciones en las Recetas 2.6 y 12.7:

function createElement<T extends keyof AllElements>(
  tag: T,
  props?: Partial<AllElements[T]>
): AllElements[T];
function createElement(tag: string, props?: Partial<HTMLElement>): HTMLElement {
  const elem = document.createElement(tag);
  return Object.assign(elem, props);
}

Ya está todo listo. Definimos un mapa de tipos con tipos mapeados y firmas de índices, utilizando parámetros de tipos genéricos para ser muy explícitos sobre nuestras intenciones. Una gran combinación de múltiples herramientas en nuestro cinturón de herramientas TypeScript.

4.8 Utilizar EsteTipo para definir esto en los objetos

Problema

Tu aplicación requiere objetos de configuración complejos con métodos, en los que this tiene un contexto diferente según el uso.

Solución

Utiliza el ThisType<T> genérico incorporado para definir el this correcto.

Debate

Los frameworks como VueJS dependen mucho de las funciones de fábrica, en las que pasas un objeto de configuración completo para definir los datos iniciales, las propiedades computadas y los métodos de cada instancia. Quieres crear un comportamiento similar para los componentes de tu aplicación. La idea es proporcionar un objeto de configuración con tres propiedades:

Una función data

El valor devuelto son los datos iniciales de la instancia. No debes tener acceso a ninguna otra propiedad del objeto de configuración en esta función.

Una propiedad computed

Esto es para las propiedades calculadas, que se basan en los datos iniciales. Las propiedades calculadas se declaran utilizando funciones. Pueden acceder a los datos iniciales igual quelas propiedades normales.

Una propiedad methods

Los métodos se pueden llamar y pueden acceder tanto a las propiedades computadas como a los datos iniciales. Cuando los métodos acceden a propiedades calculadas, lo hacen como si fueran propiedades normales: sin necesidad de llamar a la función.

Observando el objeto de configuración en uso, hay tres formas distintas de interpretar this. En data, this no tiene ninguna propiedad. En computed, cada función puede acceder al valor de retorno de data a través de this como si formara parte de su objeto. En methods, cada método puede acceder a las propiedades calculadas y a data a través de this del mismo modo:

const instance = create({
  data() {
    return {
      firstName: "Stefan",
      lastName: "Baumgartner",
    };
  },
  computed: {
    fullName() {
      // has access to the return object of data
      return this.firstName + " " + this.lastName;
    },
  },
  methods: {
    hi() {
      // use computed properties just like normal properties
      alert(this.fullName.toLowerCase());
    },
  },
});

Este comportamiento es especial, pero no infrecuente. Y con un comportamiento así, sin duda queremos confiar en los buenos tipos.

Nota

En esta lección nos centraremos sólo en los tipos, no en la implementación real, ya que eso excedería el ámbito de este capítulo.

Vamos a crear tipos para cada propiedad. Definimos un tipo Options, que vamos a refinar paso a paso. Primero está la función data. data puede ser definida por el usuario, así que queremos especificar data utilizando un parámetro de tipo genérico. Los datos que buscamos se especifican mediante el tipo de retorno de la función data:

type Options<Data> = {
  data(this: {})?: Data;
};

Así, una vez que especificamos un valor de retorno real en la función data, el marcador de posición Data se sustituye por el tipo del objeto real. Ten en cuenta que también definimos this para que apunte al objeto vacío, lo que significa que no tenemos acceso a ninguna otra propiedad del objeto de configuración.

A continuación, definimos computed. computed es un objeto de funciones. Añadimos otro parámetro de tipo genérico llamado Computed y dejamos que el valor de Computed se tipifique mediante el uso. Aquí, this cambia a todas las propiedades de Data. Como no podemos establecer this como hacemos en la función data, podemos utilizar el tipo auxiliar incorporado ThisType y establecerlo en el parámetro de tipo genérico Data:

type Options<Data, Computed> = {
  data(this: {})?: Data;
  computed?: Computed & ThisType<Data>;
};

Esto nos permite acceder, por ejemplo, a this.firstName, como en el ejemplo anterior. Por último, pero no por ello menos importante, queremos especificar methods. methods vuelve a ser especial, ya que no sólo obtienes acceso a Data a través de this, sino también a todos los métodos y a todas las propiedades computadas como propiedades.

Computed contiene todas las propiedades calculadas como funciones. Sin embargo, necesitaríamos su valor, más concretamente, su valor de retorno. Si accedemos a fullName mediante el acceso a propiedades, esperamos que sea un string.

Para ello, creamos un tipo ayudante llamado MapFnToProp. Toma un tipo que es un objeto de funciones y lo mapea a los tipos de los valores de retorno. El tipo de ayuda incorporado ReturnType es perfecto para este escenario:

// An object of functions ...
type FnObj = Record<string, () => any>;

// ... to an object of return types
type MapFnToProp<FunctionObj extends FnObj> = {
  [K in keyof FunctionObj]: ReturnType<FunctionObj[K]>;
};

Podemos utilizar MapFnToProp para establecer ThisType para un parámetro de tipo genérico recién añadido llamado Methods. También añadimos Data y Methods a la mezcla. Para pasar el parámetro de tipo genérico Computed a MapFnToProp, es necesario restringirlo a FnObj, la misma restricción del primer parámetro FunctionObj en MapFnToProp:

type Options<Data, Computed extends FnObj, Methods> = {
  data(this: {})?: Data;
  computed?: Computed & ThisType<Data>;
  methods?: Methods & ThisType<Data & MapFnToProp<Computed> & Methods>;
};

¡Y ése es el tipo! Tomamos todas las propiedades del tipo genérico y las añadimos a la función de fábrica create:

declare function create<Data, Computed extends FnObj, Methods>(
  options: Options<Data, Computed, Methods>
): any;

Mediante el uso, se sustituirán todos los parámetros de tipo genérico. Y de la forma en que se escribe Options, obtenemos todo el autocompletado necesario para asegurarnos de que no nos encontramos con problemas, como se ve en la Figura 4-1.

Este ejemplo muestra maravillosamente cómo se puede utilizar TypeScript para tipificar APIs elaboradas en las que ocurre mucha manipulación de objetos debajo de .1

tscb 0401
Figura 4-1. La configuración de los métodos en la función de fábrica que tiene todo el acceso a las propiedades correctas

4.9 Añadir contexto const a parámetros de tipo genérico

Problema

Cuando pasas valores literales complejos a una función, TypeScript amplía el tipo a algo más general. Aunque éste es el comportamiento deseado en muchos casos, en algunos quieres trabajar con los tipos literales en lugar de con el tipo ampliado.

Solución

Añade un modificador const delante de tu parámetro de tipo genérico para mantener losvalores pasadosen contexto const.

Debate

Los marcos de aplicaciones de una sola página (SPA) tienden a reimplementar muchas de las funciones del navegador en JavaScript. Por ejemplo, funciones como la API de Historial hicieron posible anular el comportamiento habitual de navegación, que los marcos SPA utilizan para cambiar de una página a otra sin una recarga real de la página, intercambiando el contenido de la página y cambiando la URL en el navegador.

Imagina que trabajas en un marco SPA minimalista que utiliza un denominado enrutador para navegar entre páginas. Las páginas se definen como componentes, y una interfaz ComponentConstructor sabe cómo instanciar y renderizar nuevos elementos en tu sitio web:

interface ComponentConstructor {
  new(): Component;
}

interface Component {
  render(): HTMLElement;
}

El enrutador debe tomar una lista de componentes y rutas asociadas, almacenadas como string. Al crear un enrutador mediante la función router, debe devolver un objeto que te permita navigate la ruta deseada:

type Route = {
  path: string;
  component: ComponentConstructor;
};

function router(routes: Route[]) {
  return {
    navigate(path: string) {
      // ...
    },
  };
}

En este momento no nos preocupa cómo se implementa la navegación real; en su lugar, queremos centrarnos en las tipificaciones de la interfaz de la función.

El enrutador funciona como está previsto; toma una matriz de objetos Route y devuelve un objeto con una función navigate, que nos permite activar la navegación de una URL a otra y renderiza el nuevo componente:

const rtr = router([
  {
    path: "/",
    component: Main,
  },
  {
    path: "/about",
    component: About,
  },
])

rtr.navigate("/faq");

Lo que se ve inmediatamente es que los tipos son demasiado amplios. Si permitimos navegar a todos los string disponibles, nada nos impide utilizar rutas falsas que no llevan a ninguna parte. Tendríamos que implementar algún tipo de gestión de errores para la información que ya está lista y disponible. Entonces, ¿por qué no utilizarla?

Nuestra primera idea sería sustituir el tipo concreto por un parámetro de tipo genérico. La forma en que TypeScript trata la sustitución genérica es que si tenemos un tipo literal, TypeScript subtipificará en consecuencia. Introducir T en lugar de Route y utilizar T["path"] en lugar de string se acerca a lo que queremos conseguir:

function router<T extends Route>(routes: T[]) {
  return {
    navigate(path: T["path"]) {
      // ...
    },
  };
}

En teoría, esto debería funcionar. Si recordamos lo que hace TypeScript con los tipos literales y primitivos en ese caso, esperaríamos que el valor se redujera al tipo literal:

function getPath<T extends string>(route: T): T {
  return route;
}

const path = getPath("/"); // "/"

Puedes leer más sobre esto en la Receta 4.3. Un detalle importante es que path en el ejemplo anterior está en un contexto const, porque el valor devuelto es inmutable.

El único problema es que estamos trabajando con objetos y matrices, y TypeScript tiende a ampliar los tipos en objetos y matrices a algo más general para permitir la mutabilidad de los valores. Si miramos un ejemplo similar, pero con un objeto anidado, veremos que TypeScript toma en su lugar el tipo más amplio:

type Routes = {
  paths: string[];
};

function getPaths<T extends Routes>(routes: T): T["paths"] {
  return routes.paths;
}

const paths = getPaths({ paths: ["/", "/about"] }); // string[]

Para los objetos, el contexto const de paths sólo sirve para el enlace de la variable, no para su contenido. Esto acaba provocando que se pierda parte de la información que necesitamos para escribir correctamente navigate.

Una forma de sortear esta limitación es aplicar manualmente const context, lo que nos obliga a redefinir el parámetro de entrada para que sea readonly:

function router<T extends Route>(routes: readonly T[]) {
  return {
    navigate(path: T["path"]) {
      history.pushState({}, "", path);
    },
  };
}

const rtr = router([
  {
    path: "/",
    component: Main,
  },
  {
    path: "/about",
    component: About,
  },
] as const);

rtr.navigate("/about");

Esto funciona, pero también requiere que no olvidemos un detalle muy importante al codificar. Y recordar activamente las soluciones provisionales es siempre una receta para el desastre.

Afortunadamente, TypeScript nos permite solicitar contexto const a los parámetros de tipo genérico. En lugar de aplicarlo al valor, sustituimos el parámetro de tipo genérico por un valor concreto pero en contexto const añadiendo el modificador const al parámetro de tipo genérico:

function router<const T extends Route>(routes: T[]) {
  return {
    navigate(path: T["path"]) {
      // tbd
    },
  };
}

Entonces podremos utilizar nuestro router tal y como estamos acostumbrados e incluso obtener autocompletado para las posibles rutas:

const rtr = router([
  {
    path: "/",
    component: Main,
  },
  {
    path: "/about",
    component: About,
  },
])

rtr.navigate("/about");

Mejor aún, obtenemos errores apropiados cuando introducimos algo falso:

const rtr = router([
  {
    path: "/",
    component: Main,
  },
  {
    path: "/about",
    component: About,
  },
])

rtr.navigate("/faq");
//             ^
// Argument of type '"/faq"' is not assignable to
// parameter of type '"/" | "/about"'.(2345)

Lo bonito: todo está oculto en la API de la función. Lo que esperamos queda más claro, la interfaz nos indica las restricciones, y no tenemos que hacer nada extra al utilizar router para garantizar la seguridad de tipos.

1 Un agradecimiento especial a los creadores de Type Challenges por este bello ejemplo.

Get Libro de cocina de TypeScript now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.