Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

Tags:

typescript

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?

like image 657
Max Yankov Avatar asked May 15 '20 22:05

Max Yankov


People also ask

How do you iterate through a record TypeScript?

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.

How do you iterate over all properties of an object in TypeScript?

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.


1 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

like image 117
jcalz Avatar answered Oct 01 '22 18:10

jcalz