dlubbers
dlubbers

Reputation: 61

Index Signature with Typescript with Array of Objects

I've been using Typescript for a few months now, but am stuck with getting my index signatures to work properly.

sensorTestData = [
  {
    altitude: 249.74905877223617
    gas: 4361
    humidity: 53.16487239957308
    pressure: 30.100467823211194
    temperature: 69.0322421875
    timestamp: "2022-02-23 00:10:12.377165"
  }, 
  {
    altitude: 249.5376138919663
    gas: 700
    humidity: 53.15552212315537
    pressure: 30.100694114206632
    temperature: 69.07759374999999
    timestamp: "2022-02-23 00:10:13.574070"
  }
 ]

The array of objects look like this: { temperature: number; humidity: number; pressure: number; altitude: number; gas: number; timestamp: string; }

I've tried multiple different ways to add [metric: string]: any and can't seem to get anywhere without using "any."

Here is an example of a function I am using with the array of objects:

const getMax = (metric: string) => {
    return parseFloat(
      Math.max(...sensorTestData.map((obj: {
            temperature: number;
            humidity: number;
            pressure: number;
            altitude: number;
            gas: number;
            timestamp: string;
          }) => obj[metric])).toFixed(2)
    );

  };
  console.log("maxTemp", getMax("temperature"));
  console.log("maxHumidity", getMax("humidity"));```

Error thrown: Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{ temperature: number; humidity: number; pressure: number; altitude: number; gas: number; timestamp: string; }'.
  No index signature with a parameter of type 'string' was found on type '{ temperature: number; humidity: number; pressure: number; altitude: number; gas: number; timestamp: string; }'.ts(7053)

Upvotes: 1

Views: 2717

Answers (1)

jcalz
jcalz

Reputation: 327819

In order for this to compile without error, you need the metric parameter to be more specific than just string. After all, if you pass some arbitrary string in to getMax() like getMax("oopsieDaisy") then you'll be trying to get Math.max() of a bunch of undefined values (which is probably an error). The compiler error is warning you about this very issue; the compiler has no idea what might happen for an arbitrary string. So you need to make sure that metric is one of the known keys of the sensorTestData elements.

In fact, it specifically has to be those keys whose value type is known to be number; if you call getMax("timestamp") you will be getting Math.max() of a bunch of string values, (which is also probably an error).

If we define the Data interface like this to correspond to the elements of sensorTestData,

interface Data {
  temperature: number;
  humidity: number;
  pressure: number;
  altitude: number;
  gas: number;
  timestamp: string;
}

Then we want metric to be of type "temperature" | "humidity" | "pressure" | "altitude" | "gas" (that's the union of the known string literal keys whose values in Data are number).

If we define a type alias for that:

type KeysForNumericData = "temperature" | "humidity" | "pressure" | "altitude" | "gas"

And we annotate the type of metric with it:

const getMax = (metric: KeysForNumericData) => {
  return parseFloat(
    Math.max(...sensorTestData.map((obj) => obj[metric])).toFixed(2)
  );
};

Then that compiles with no error. And you get type checking when you call getMax() also:

getMax("temperature"); // okay
getMax("humidity"); // okay
getMax("oopsieDaisy"); // error!
// Argument of type '"oopsieDaisy"' is not assignable to 
// parameter of type 'KeysForNumericData'.
getMax("timestamp"); // error!
// Argument of type '"timestamp"' is not assignable to 
// parameter of type 'KeysForNumericData'.

That's basically the answer, except that if Data ever changes, the type of KeysForNumericData will not automatically update. It might be nice to have the compiler compute KeysForNumericData in terms of Data. Here's one way to do it:

type KeysMatching<T, V> = {
  [K in keyof T]-?: T[K] extends V ? K : never
}[keyof T]

type KeysForNumericData = KeysMatching<Data, number>;
//type KeysForNumericData = "temperature" | "humidity" | "pressure" | "altitude" | "gas"

I've defined a utility type called KeysMatching<T, V> (explained in this answer and this answer and probably elsewhere) which returns the keys of T whose property values are assignable to V.
And so to get the keys of Data whose values are assignable to number, we write KeysMatching<Data, number>.

There are a few different ways to implement KeysMatching. A brief explanation of this one: we are mapping over the keys K of T and for each property T[K] at that key we are checking via conditional type if that property is assignable to number. If so, we include that key (K), otherwise we exclude it (with the never type). That mapped type (everything before the [keyof T]) for KeysMatching<Data, number> evaluates to something like {temperature: "temperature", humidity: "humidity", ... timestamp: never} where only the timestamp key has a never property value. Then we index into this mapped type with keyof T, producing the union of property values, which in this case is "temperature" | "humidity" | ... | "gas" | never". Since never is an impossible type, it gets absorbed into all unions (XXX | never is equivalent to XXX) and so the result is the desired union of keys.

And now if Data changes, KeysForNumericData will automatically change to fit:

interface Data {
  temperature: number;
  humidity: number;
  pressure: number;
  altitude: number;
  gas: number;
  timestamp: string;
  latitude: number;
  longitude: number;
  flavor: string;
  glutenFree: boolean;
}

type KeysForNumericData = KeysMatching<Data, number>;
/* type KeysForNumericData = "temperature" | "humidity" | "pressure" | 
     "altitude" | "gas" | "latitude" | "longitude" */

Playground link to code

Upvotes: 1

Related Questions