Black Sheep Code
rss_feed

TypeScript - indexing into a mapped type

Published:

Let say we have some data representing rows in a table:


const rows = [
  {
    id: "1", 
    name: "bob", 
    address: "123 bobs way", 
    favouriteNumber: 123,
    favouriteColors: ["red", "green"], 
  },
  {
    id: "1", 
    name: "cindy", 
    address: "123 cindy close", 
    favouriteNumber: 999,
    favouriteColors: ["purple"], 
  }
]

And then we have some column data that represent the row keys, and how the corresponding row values should be handled,

const columns = [
  {
    key: "name", 
    renderValue: (value) => { // 👈  Value should type 'string'

    }
  },
  {
    key: "favouriteNumber", 
    renderValue: (value) => { // 👈  Value should type 'number'

    }
  },
    {
    key: "favouriteColors", 
    renderValue: (value) => { // 👈  Value should type 'string[]'

    }
  }
]

How can type this such that the typings of our renderValue functions have their types derived, from what is seen in the rows data?

Step 1 - Declare a type for our rows.

type RowData = Record<string, unknown> & {key: string}; 

So far, so good.

Step 2 - Declare a type for our columns

We want to derive our column data type from our row data. Using a generic here is appropriate.

To start, let's just get the keys of the row data.

type ColumnData<T extends RowData> = keyof T; // 👈  just the keys of T

//type A = "foo" | "id"
type A = ColumnData<{foo: number, id: string}>; 

Now, let's create an object type that has all the keys of the row data, mapped to just type 'unknown'.

Here we use a mapped type:

type ColumnData<T extends RowData> = { // 👈 it's an object type
  [K in keyof T]: unknown;             // 👈 with every key of T, with value type 'unknown'
}

// type B = {
//     foo: unknown;
//     id: unknown;
// }
type B = ColumnData<{foo: number, id: string}>; 

Now, let's use the actual values of the row data.

type ColumnData<T extends RowData> = { // 👈 it's an object type
  [K in keyof T]: T[K]                 // 👈 with every key of T, with value type 'whatever every values can be accessed by K'
}
// type C = {
//     foo: number;
//     id: string;
// }
type C = ColumnData<{foo: number, id: string}>; 

We've effectively cloned the the row data type.

The [K in keyof T] effectively acts as a for loop, introducing a new generic type K, which we use to index the original T type, to "get all values accessible by this type K".

So let's create those key and renderValue properties.

type ColumnData<T extends RowData> = {// 👈 it's an object type
  [K in keyof T]: {                       // 👈 for each key - declare an object type with... 
    key: K;                               // 👈 property 'key'... 
    renderValue: (value: T[K]) => void;   // 👈 and property 'renderValue'
  }
}
// type D = {
//     foo: {
//         key: "foo";
//         renderValue: (value: number) => void;
//     };
//     id: {
//         key: "id";
//         renderValue: (value: string) => void;
//     };
// }
type D = ColumnData<{foo: number, id: string}>; 

This is looking good, but it's not quite what we want. We don't want a full object, we're looking for just the types accessed by those "foo" and "id" keys.

This is where we use a second index type of the whole object itself:

type ColumnData<T extends RowData> = { // 👈 it's an object type
  [K in keyof T]: {                    // 👈 for each key in T...     
    key: K;                               
    renderValue: (value: T[K]) => void;   
  }
}[keyof T]                             // 👈 From the top level object type - what values can be accessed by the keys of T?

// type E = {
//     key: "id";
//     renderValue: (value: string) => void;
// } | {
//     key: "foo";
//     renderValue: (value: number) => void;
// }
type E = ColumnData<{foo: number, id: string}>; 

And there we have it!

This gives us great type behaviour, we can do things like:

function processColumn<T extends RowData>(rows: Array<T>, column: ColumnData<T>) {
  rows.forEach((v) => {
    const value = v[column.key];
    column.renderValue(value);
  })
}

const rows = [
  { id: "1", x: 9, y: "hello" },
  { id: "2", x: 10, y: "abc" },
]

// properly typed
processColumn(rows,
  {
    key: "x",
    renderValue: (value) => {
      // number
      value;
    }
})

processColumn(rows,
  {
    // 👇 TypeScript knows the key doesn't exist
    //Type '"z"' is not assignable to type '"id" | "x" | "y"'.(2322)
    key: "z",
    renderValue: (value) => {
      // number
      value;
    }
})

processColumn(rows,
  {
    key: "x",
    renderValue: (value) => {
      // 👇 TypeScript knows what the value type is 
      // Type 'number' is not assignable to type 'string'.(2322)
      let value2: string = value; 
    }
})

For more information - see this Stack Overflow answer.



Questions? Comments? Criticisms? Get in the comments! 👇

Spotted an error? Edit this page with Github