typescript - ¿Cómo puedo iterar sobre las claves de un Record de manera segura en cuanto a tipos?
Construyamos un registro de ejemplo sobre algunas propiedades.
type HumanProp =
| "weight"
| "height"
| "age"
type Human = Record<HumanProp, number>;
const alice: Human = {
age: 31,
height: 176,
weight: 47
};
Para cada propiedad, también quiero agregar una etiqueta legible por humanos:
const humanPropLabels: Readonly<Record<HumanProp, string>> = {
weight: "Weight (kg)",
height: "Height (cm)",
age: "Age (full years)"
};
Ahora, usando este tipo de registro y las etiquetas definidas, quiero iterar sobre dos registros con los mismos tipos de clave.
function describe(human: Human): string {
let lines: string[] = [];
for (const key in human) {
lines.push(`${humanPropLabels[key]}: ${human[key]}`);
}
return lines.join("\n");
}
Sin embargo, estoy recibiendo un error:
Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'Readonly<Record<HumanProp, string>>'.
No index signature with a parameter of type 'string' was found on type 'Readonly<Record<HumanProp, string>>'.
¿Cómo puedo implementar esta funcionalidad en Typescript correctamente?
Para aclarar, la solución que estoy buscando, independientemente de si utilizará el tipo Record, objetos simples, tipos, clases, interfaces u otra cosa, debería tener las siguientes propiedades:
Cuando quiero definir una nueva propiedad, solo necesito hacerlo en un lugar (como en HumanProp arriba), y no repetirme.
Después de definir una nueva propiedad, todos los lugares donde debería agregar un nuevo valor para esta propiedad, como cuando estoy creando
aliceohumanPropLabels, se iluminan con errores de tipo en tiempo de compilación, no con errores en tiempo de ejecución.El código que itera sobre todas las propiedades, como la función
describe, debe permanecer sin cambios cuando creo una nueva propiedad.
¿Es siquiera posible implementar algo así con el sistema de tipos de TypeScript?
Respuesta #1
Creo que la forma correcta de hacer esto es crear un arreglo inmutable de los nombres de las claves y darle un tipo estrecho para que el compilador lo reconozca como que contiene tipos literales de cadena en lugar de solo string. Esto es más fácil con una afirmación de const:
const humanProps = ["weight", "height", "age"] as const;
// const humanProps: readonly ["weight", "height", "age"]
Entonces puedes definir HumanProp en términos de esto:
type HumanProp = typeof humanProps[number];
Y el resto de tu código debería funcionar más o menos tal cual, excepto que cuando recorras las claves deberías usar tu arreglo inmutable de arriba en lugar de Object.keys():
type Human = Record<HumanProp, number>;
const alice: Human = {
age: 31,
height: 176,
weight: 47
};
const humanPropLabels: Readonly<Record<HumanProp, string>> = {
weight: "Weight (kg)",
height: "Height (cm)",
age: "Age (full years)"
};
function describe(human: Human): string {
let lines: string[] = [];
for (const key of humanProps) { // <-- iterate this way
lines.push(`${humanPropLabels[key]}: ${human[key]}`);
}
return lines.join("\n");
}
La razón para no usar Object.keys() es que el compilador no puede verificar que un objeto del tipo Human tendrá solo las claves declaradas en Human. Los tipos de objetos en TypeScript son abiertos/extensibles, no cerrados/exactos. Esto permite que la extensión de interfaces y la herencia de clases funcionen:
interface SuperHero extends Human {
powers: string[];
}
declare const captainStupendous: SuperHero;
describe(captainStupendous); // works, a SuperHero is a Human
No querrías que describe() explotara porque pasas un SuperHero, un tipo especial de Human con una propiedad extra powers. Así que en lugar de usar Object.keys(), que correctamente produce string[], deberías usar la lista codificada de propiedades conocidas para que el código como describe() ignore cualquier propiedad extra si están presentes.
Y, si agregas un elemento a humanProps, verás errores donde los desees mientras describe() permanecerá sin cambios:
const humanProps = ["weight", "height", "age", "shoeSize"] as const; // added prop
const alice: Human = { // error!
age: 31,
height: 176,
weight: 47
};
const humanPropLabels: Readonly<Record<HumanProp, string>> = { // error!
weight: "Weight (kg)",
height: "Height (cm)",
age: "Age (full years)"
};
function describe(human: Human): string { // okay
let lines: string[] = [];
for (const key of humanProps) {
lines.push(`${humanPropLabels[key]}: ${human[key]}`);
}
return lines.join("\n");
}
Está bien, espero que eso ayude; ¡buena suerte!
Enlace de Playground al código
Respuesta #2
Se han proporcionado buenas respuestas a esta pregunta específica arriba, pero, en mi caso, mi error fue intentar usar un tipo Record en primer lugar.
Dando un paso atrás, si estás intentando asignar claves a valores y recorrerlos de manera segura con respecto al tipo, podría ser mejor usar un Map:
type Test = 'harry' | 'sam' | 'alex';
const m = new Map<Test, number>();
for (const [k, v] of m)
{
// this won't compile because k is type Test
if (k === 'foo') { console.log(v); }
}
La desventaja de esto es que, a diferencia de un Record, un Map no puede garantizar que todas las claves en el tipo Test hayan sido mapeadas... pero terminé en esta página como un principiante tratando de averiguar cómo iterar sobre un Record<number, string>, y obviamente mi error fue recurrir al tipo Record en primer lugar.
De todos modos, este entendimiento me ayudó. Quizás ayude a alguien más.
