How to use Index Signatures for Dynamic Data

Notes from Effective TypeScript

What do we mean by dynamic data

We mean data which changes or can change. An example given is data in a spreadsheet where we don't even know the column names, nor do we know what values will exist in the rows. Think weather data, students who will enroll in a school next year, and server environment variables -- these are all examples of dynamic data.

What are index signatures?

Index signatures are a special way Typescript lets us specify the type of an object whose keys and values are dynamic.

Bring to mind a row in a spreadsheet representing different types of rockets. Challenge yourself to create a type for an object representing a row in this spreadsheet without any idea what the column names are, or how many columns there are.

Here's an index signature solving this problem:

type RocketRow = {

[key: string]: string // <-- this is the index signature

};

What are the three parts of an index signature?

This is the structure of an index signature:

[key-name: key-name-type]: key-type
  1. A key name can be any name you want.
  2. The type for the key name has to be some combination of string, number or symbol, but typically is just string.
  3. The key type can be any valid type.

What is the main limitation of index signatures?

Lack of precision.

We cannot specify a precise:

  • maximum number of keys
  • minimum set of required keys
  • set of valid key names

Nor can we have unique types per key.

Basically anything that matches the key type or the value type goes, and you can have any number of keys, including an empty object {}.

Due to this lack of precision we lose TypeScript's language services like autocomplete and inference when working with index signatures.

So what should we use index signatures for?

For truly dynamic data. We'll use an example from Effective TypeScript to illustrate this.

The following example of a CSV parser uses an index signature to represent the row objects it returns.

This is great! Because we're freed of the need to worry about how many columns exist in the spreadsheet, or to know ahead of time exactly what the column names are when forming our objects.

function parseCSV(input: string): {[columnName: string]: string}[] {
  const lines = input.split('\n');
  const [headerLine, ...rows] = lines;
  const headers = headerLine.split(',');
  return rows.map(rowStr => {
    const row: {[columnName: string]: string} = {};
    rowStr.split(',').forEach((cell, i) => {
      row[headers[i]] = cell;
    });
    return row;
  });
}

Vanderkam, Dan. Effective TypeScript . O'Reilly Media. Kindle Edition.

How and when are assertions used for more specific types of index signatures?

Following from our previous example, and borrowing again from the book, say in the future we get to know more specifically what our row type is, we can assert this as the output type of parseCSV.

In this example we define an interface for our row types then assert the return value of ParseCSV as an array of ProductRow objects.

interface ProductRow {
  productId: string;
  name: string;
  price: string;
}

declare let csvData: string;
const products = parseCSV(csvData) as unknown as ProductRow[];

For the curious:

What is the danger in narrowing index signature types?

The danger is that we can never really be 100% sure that the data we get will be what we expect.

Continuing from the ProductRow example above, someone might mistakenly omit a field in the spreadsheet or put in a value of a wrong type.

How do we mitigate against the danger of narrowing the value type of an index signature?

We accept that sometimes we may get values of some other type or none at all in which case we would expect undefined. So we expand the value type of our index signature to reflect this. For example:

function safeParseCSV(
  input: string
): {[columnName: string]: string | undefined}[] {
  return parseCSV(input);
}

This adds some protection against runtime errors if say we try to loop through the row objects returned by parseCSV

const safeRows = safeParseCSV(csvData);
for (const row of safeRows) {
  prices[row.productId] = Number(row.price);
      // ~~~~~~~~~~~~~ Type 'undefined' cannot be used as an index type
}

Why would a Map type be better than an index signature for associative arrays?

There are certain keys which exist on all JavaScript objects through Object.prototype. One of them is constructor.

To illustrate, running the following in the typescript playground shows us that quirk has a constructor key even though we never defined one.

const quirk: {[key: string]: number} = {hello: 1}

console.log(quirk['constructor'])
// console output: ƒ Object() { [native code] }

Using Map/Set types over index signatures allows us to avoid quirks such as accessing Object.prototype values when accessing the keys of an object built using an index signature.

For the curious:

What to do when you know the keys in your type but are not sure how many there will be?

Don't use an index signature, use optional types or a union type.

interface Row1 { [column: string]: number }  // Too broad
interface Row2 { a: number; b?: number; c?: number; d?: number }  // Better
type Row3 =
    | { a: number; }
    | { a: number; b: number; }
    | { a: number; b: number; c: number;  }
    | { a: number; b: number; c: number; d: number };

Vanderkam, Dan. Effective TypeScript . O'Reilly Media. Kindle Edition.

What to do when string is too broad as a type for the index-signature key?

Sometimes keys are limited to string values such as 'x', 'y', 'z'. Using a string type in this case is way too broad.

Use a record type or a mapped type

type Vec3D = Record<'x' | 'y' | 'z', number>;
// Type Vec3D = {
//   x: number;
//   y: number;
//   z: number;
// }

type Vec3D = {[k in 'x' | 'y' | 'z']: number};
// Same as above
type ABC = {[k in 'a' | 'b' | 'c']: k extends 'b' ? string : number};
// Type ABC = {
//   a: number;
//   b: string;
//   c: number;
// }

Vanderkam, Dan. Effective TypeScript . O'Reilly Media. Kindle Edition.