typescripttype-inferencegeneric-type-parameters

How to correctly infer types across object properties?


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.

Playground


Solution

  • 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
              }
          }
        ],
      };
    };
    

    Playground