I am trying to create a grid structure for my application and want to make use of generics to correctly infer the type of a cell's value based on its name. The inference I am trying to achieve is conceptually similar to the setProperty example in the TS docs.
I have already been able to get most of what I want to achieve done, but I seem to have reached a point where my brain is incapable of figuring out that last step needed.
First things first, here's the interfaces I've defined:
interface Grid<T> {
data: T[];
columns: () => Column<T, keyof T>[];
}
interface Column<T, K extends keyof T> {
name: K;
click: (cell: Cell<T, K>) => void;
}
interface Cell<T, K extends keyof T> {
row: T;
column: K;
value: T[K];
}
And here's what I want to do with them:
interface TestData {
title: string;
value: number;
}
const test = () => {
const rows: TestData[] = [{ title: 'title', value: 1 }];
const myGrid: Grid<TestData> = {
data: rows,
columns: () => [
{
name: 'title',
click: (cell) => {
// Expected:
// cell: Cell<TestData, "title">
// Actual:
// cell: Cell<TestData, "title" | "value">
},
},
],
};
};
So as you can see, type inference works almost perfectly here, but I simply have not been able to figure out how to get that last bit of cell
being a Cell<TestData, "value">
instead of a Cell<TestData, "title" | "value">
.
My guess is that I must have messed up somewhere between the return type of the columns
in the Grid
and the param type of the click
in the Column
, and despite not knowing how to solve this, I have a strong feeling this has to work somehow because after all, I am able to do stuff like this, where everything gets inferred perfectly:
const testInference = <T, K extends keyof T>(data: Cell<T, K>) => undefined;
const row: TestData = { title: 'title', value: 1 };
testInference({
row,
column: 'title',
value: 'bla',
});
So the question essentially is how to get that K
of the click
's parameter to be correctly inferred from the name
.
This is because the compiler does what you told it to do. Take a step back and look at what the generic type parameters actually mean: they are templates with placeholders. You annotated the myGrid
variable with the Grid<TestData>
. Thus, you told the compiler that T
is TestData
. So far so good.
Then, you told it that columns
property is a function that returns an array of generic interfaces: Column<T, keyof T>[]
, meaning that keyof T
here is keyof TestData
. Now, the keyof
operator returns a union of keys, which is exactly what you get: "title" | "value"
.
But what you wanted instead is to establish a one-to-one relationship between array elements and keys, therefore Column<T, keyof T>[]
will not do. You need to map over the union of keys. So, with that in mind, the Grid
interface could be amended to this:
interface Grid<T> {
data: T[];
columns: () => { [P in keyof T] : Column<T, P> }[];
}
However, this is not enough to get the desired result because the elements of the array now constitute a union of objects which makes it a discriminated union making properties inaccessible (as only shared properties can be accessed).
Thus, you need a way to create a tuple type out of the union. Not a pretty task, given that unions are unordered; and a pretty explosive one at that (since each new key increases the number of permutations drastically, see this answer), so proceed with caution. A utility could look like this:
type ToArr<T> = { [P in keyof T]: [T[P]?, ...Exclude<keyof T , P > extends never ? []: ToArr<Omit<T,P>> ] }[keyof T];
The Grid
interface should correspondingly be amended to this:
interface Grid<T> {
data: T[];
columns: () => ToArr<{ [P in keyof T] : Column<T, P> }>;
}
The resulting type behaves as expected:
const test = () => {
const rows: TestData[] = [{ title: 'title', value: 1 }];
const myGrid: Grid<TestData> = {
data: rows,
columns: () => [
{
name: 'title',
click: (cell) => {
cell.value.charCodeAt(0) //OK
},
},
{
name: "value",
click: (cell) => {
cell.value.toExponential(3) //OK
}
}
],
};
};