Black Sheep Code

TypeScript - indexing into a mapped type

Published:

Let's say we're trying to create a component that represents some table data, we want to use the thing like this:


createTable([
  {
    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"], 
  }
], 
[
  {
    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?

TL;DR

Do this:

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

type ColumnData<T extends RowData> = { 
  [K in keyof T]: {                      
    key: K;                               
    renderValue: (value: T[K]) => void;   
  }
}[keyof T]   

function processTable<T extends RowData>(rows: Array<T>, columns: Array<ColumnData<T>>){

}

TypeScript Playground

The Problem Approach

Step 1 - Declare a type for our rows.

We might start with something like this:

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

Here, we're saying that the row data can be any object - so long as it has an 'id' property to uniquely identify it.

So far, so good.

Step 2 - Declare a type for our columns

We want to derive our column data type from our row data - the possible keys of the column should only be the properties of the RowData.

Using a generic here is appropriate.

We could do something like this:

type ColumnData<TRowData extends RowData, TKey extends keyof TRowData> = {
    key: TKey;
    renderValue: (value: TRowData[TKey]) => string; 
}

Looks good...

This looks like it might work:

processSingleRow({
    id: "123", 
    a: 1,
    b: {
        x: 1, 
        y: 2
    }
}, {
    key: "a",
    renderValue :(value) => {
        //(parameter) value: number
        return `${value}`;
    }
});

processSingleRow({
    id: "123", 
    a: 1,
    b: {
        x: 1, 
        y: 2
    }
}, {
    key: "b",
    renderValue :(value) => {
        // (parameter) value: {
        //     x: number;
        //     y: number;
        // }
        return `${value.x},${value.y}`;
    }
});


TypeScript Playground

Here, we see that the renderData function is typed correctly, giving us the correct narrow type for the value accessed by that key.

...but it doesn't really work

Look what happens when we try to allow multiple columns to be processed:

function processTable<TRowData extends RowData, TKey extends keyof TRowData>(row: Array<TRowData>, column: Array<ColumnData<TRowData, TKey>>) {

}

processTable(
    [{
        id: "123",
        a: 1,
        b: {
            x: 1,
            y: 2
        }
    }], [{
        key: "a",
        renderData: (value) => {
            // (parameter) value: number | {
            //     x: number;
            //     y: number;
            // }
            return `${value}`;
        }
    },
    {
        key: "b",
        renderData: (value) => {

            // (parameter) value: number | {
            //     x: number;
            //     y: number;
            // }
            //Property 'x' does not exist on type 'number | { x: number; y: number; }'.
            return `${value.x},${value.y}`;
        }
    },
]);

TypeScript Playground

What's going on here?

Here is a Stack Overflow question that gets into the details.

The problem here, is this part:

TKey extends keyof TRowData

Let's say we got rid of that generic parameter completely, and just did this:

function processTable<TRowData extends RowData>(row: Array<TRowData>, column: Array<ColumnData<TRowData, keyof TRowData>>) {

}

This actually makes the problem worse, but helps us understand it:

function processTable<TRowData extends RowData>(row: Array<TRowData>, column: Array<ColumnData<TRowData, keyof TRowData>>) {

}


processTable(
    [{
        id: "123",
        a: 1,
        b: {
            x: 1,
            y: 2
        }
    }], [{
        key: "a",
        renderValue: (value) => {
            // Can also be a string!
            // (parameter) value: string | number | {
            //     x: number;
            //     y: number;
            // }
            return `${value}`;
        }
    },
]);

TypeScript Playground

Now the possible types of value are any types that can be accessed via the keys of TRowData

Essentially we're doing this:

type MyRowData = {
    id: string; 
    a: number; 
    b: {
       x: number; 
       y: number; 
    }
}

// string | number | {
//     x: number;
//     y: number; 
// }
type PossibleValues = MyRowData["id" | "a" | "b"]; 

or more concretely, this:

type MyRowData = {
    id: string; 
    a: number; 
    b: {
       x: number; 
       y: number; 
}

type MyColumnData = {
    key: "id" | "a" | "b", 
    renderValue: (value: MyRowData["id" | "a" | "b"]) => string; 
}

TypeScript Playground

This hopefully highlights the problem - we have a union type for the key property, and we have a union type for the value parameter, but there's no relationship between the two, nothing to enforce that when we have the "a" key, we want the MyRowData["a"] type.

What we actually want is a type that looks like this:

type MyColumnData = {
    key: "id", 
    renderValue: (value: MyRowData["id"]) => string; 
} |  {
    key: "a", 
    renderValue: (value: MyRowData["a"]) => string; 
} |  {
    key: "b", 
    renderValue: (value: MyRowData["b"]) => string; 
}

where the value parameter is MyRowData["a"] only when the key is "a".

The Solution Approach - Indexing into a mapped type

Here, I'm going to talk through building up the whole solution step by step.

Step 1 - Create a type for our row data:

Just like before - easy:

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

TypeScript Playground

Step 2 - Just the keys of the row data

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}>; 

TypeScript Playground

Step 3 - A mapped type to reproduce the keys onto a new object

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}>; 

TypeScript Playground

Step 4 - A mapped type with the value types reproduced

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}>; 

TypeScript Playground

All we've done at this point is we've 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".

Step 5 - A mapped type with key and renderValue properties on it

Let's create those key and renderValue properties.

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

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}>; 

TypeScript Playground

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.

Step 6 - Picking of just the one type that we need from our mapped type

Now 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}>; 

TypeScript Playground

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" },
]

processColumn(rows,
  {
    // ✅ Behaving correctly
    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; 
    }
})

TypeScript Playground



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

Spotted an error? Edit this page with Github