How can I iterate over Record keys in a proper type-safe way?

Viewed 13751

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:

  1. 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.

  2. 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.

  3. 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?

3 Answers

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

Sometimes you have to bend to use Typescript... other times it's probably better to make Typescript bend for you. I would opt for something MUCH simpler than trying to maintain some concrete string array instance (and keep it in sync with the interfaces). I wouldn't use HumanProps. In my opinion this simple change (adding as keyof Human) is the best way to avoid your error (and IMO it even makes the code more readable):

function describe2(human: Human): string {
    let lines: string[] = [];
    for (const key in human) {
        lines.push(`${humanPropLabels[key as keyof Human]}: ${human[key as keyof Human]}`);
    }
    return lines.join("\n");
}
// instead of
Record<number, MyType>

// might be better to use
Map<number, MyType>

// then you can iterate over the map without
// javascript casting all your keys to strings

const m = new Map<number, string>();

m.forEach((value: string, key: number) =>
 console.log(`m[${key}]=${value}`)
);
Related