This article is a part of the series "TypeScript bits"
TypeScript - indexing into a mapped type
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>>){
}
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}`;
}
});
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}`;
}
},
]);
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}`;
}
},
]);
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;
}
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};
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}>;
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}>;
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}>;
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}>;
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}>;
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;
}
})
Questions? Comments? Criticisms? Get in the comments! 👇
Spotted an error? Edit this page with Github