Let's build an example record over some properties.
type HumanProp =
| "weight"
| "height"
| "age"
type Human = Record<HumanProp, number>;
const alice: Human = {
age: 31,
height: 176,
weight: 47
};
For each property, I also want add a human-readable label:
const humanPropLabels: Readonly<Record<HumanProp, string>> = {
weight: "Weight (kg)",
height: "Height (cm)",
age: "Age (full years)"
};
Now, using this Record type and defined labels, I want to iterate over two record with the same key types.
function describe(human: Human): string {
let lines: string[] = [];
for (const key in human) {
lines.push(`${humanPropLabels[key]}: ${human[key]}`);
}
return lines.join("\n");
}
However, I'm getting an 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>>'.
How can I implement this functionality in Typescript properly?
To clarify, the solution I'm looking for, regardless of whether it will use Record
type, plain objects, types, classes, interfaces or something else, should have following properties:
When I want to define a new property, I only need to do it in one place (like in HumanProp above), and don't repeat myself.
After I define a new property, all the places where I should add a new value for this property, like when I'm creating alice
, or humanPropLabels
, light up type errors at compilation time, not in runtime errors.
Code that iterates over all properties, like describe
function, should remain unchanged when I create a new property.
Is it even possible to implement something like that with Typescript's type system?
Use let k: keyof T and a for-in loop to iterate objects when you know exactly what the keys will be. Be aware that any objects your function receives as parameters might have additional keys. Use Object. entries to iterate over the keys and values of any object.
If you want to iterate over the keys and values in an object, use either a keyof declaration ( let k: keyof T ) or Object. entries . The former is appropriate for constants or other situations where you know that the object won't have additional keys and you want precise types.
I think the right way to do this is to create an immutable array of the key names and give it a narrow type so that the compiler recognizes it as containing string literal types instead of just string
. This is easiest with a const
assertion:
const humanProps = ["weight", "height", "age"] as const;
// const humanProps: readonly ["weight", "height", "age"]
Then you can define HumanProp
in terms of it:
type HumanProp = typeof humanProps[number];
And the rest of your code should work more or less as-is, except that when you iterate over keys you should use your immutable array above instead of 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");
}
The reason not to use Object.keys()
is that the compiler can't verify that an object of type Human
will only have the keys declared in Human
. Object types in TypeScript are open/extendible, not closed/exact. This allows interface extension and class inheritance to work:
interface SuperHero extends Human {
powers: string[];
}
declare const captainStupendous: SuperHero;
describe(captainStupendous); // works, a SuperHero is a Human
You wouldn't want describe()
to explode because you pass in a SuperHero
, a special type of Human
with an extra powers
property. So instead of using Object.keys()
which correctly produces string[]
, you should use the hardcoded list of known properties so that code like describe()
will ignore any extra properties if they are present.
And, if you add an element to humanProps
, you'll see errors where you want while describe()
will be unchanged:
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");
}
Okay, hope that helps; good luck!
Playground link to code
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With