Search code examples
javascriptreactjstypescript

deep keyof of a nested object in typescript


I want to create a custom table. The prop in the cell and footer in createColumnHelper function should return the correct object key/type instead of any.

For example, I have a nested object progress in my DATA. Or if not an object, it also should return the correct type.

I attached screenshots of how I see in the IDE

Helper.ts

import { ReactElement } from "react";

export type Column<T> = {
  header: string | (() => ReactElement);
  accessor: (row: T) => any;
  cell?:(prop: any) => ReactElement | string;
  footer?: (prop: any) => ReactElement | string;
};

export const createColumnHelper = <T>() => {
  return {
    accessor: (
      accessor: keyof T | ((row: T) => any),
      column: {
        header: string | (() => ReactElement);
        cell?: (prop: any) => ReactElement | string;
        footer?: (prop: any) => ReactElement | string;
      }
    ) => {
      return {
        header: column.header,
        accessor: (row: T) => (typeof accessor === "function" ? accessor(row) : row[accessor]),
        cell: column.cell,
        footer: column.footer,
      } as Column<T>;
    }
  };
};

TableComponents.tsx

import React from "react";
import { createColumnHelper } from "./columnHelper";

type Person = {
  firstName: string
  lastName: string
  age: number
  visits: number
  status: string
  progress: {
    ok?: number;
    no?: string
  }
}

const DATA: Person[] = [
  {
    firstName: 'tanner',
    lastName: 'linsley',
    age: 24,
    visits: 100,
    status: 'In Relationship',
    progress: {
      ok: 50
    },
  },
  {
    firstName: 'tandy',
    lastName: 'miller',
    age: 40,
    visits: 40,
    status: 'Single',
    progress: {
      no: 'bad'
    },
  },
]

const columnHelper = createColumnHelper<Person>();

const columns = [
  columnHelper.accessor(
    'firstName', {
    header: 'First Name',
    cell: val => val,
  }),
  columnHelper.accessor(
    'progress', {
      header: 'Progress',
      cell: prop => prop.ok,
    }),
  columnHelper.accessor(row => row, {
    header: () => <span>Age</span>,
    cell: prop => <span>{prop.age}</span>,
  }),
];

const TableComponent: React.FC = () => {
  return (
    <table>
      <thead>
      <tr>
        {columns.map((column, index) => (
          <th key={index}>
            {typeof column.header === "function" ? column.header() : column.header}
          </th>
        ))}
      </tr>
      </thead>
      <tbody>
      {DATA.map((row, rowIndex) => (
        <tr key={rowIndex}>
          {columns.map((column, colIndex) => (
            <td key={colIndex}>
              {column.cell ? column.cell(column.accessor(row)) : column.accessor(row)}
            </td>
          ))}
        </tr>
      ))}
      </tbody>
      <tfoot>
      <tr>
        {columns.map((column, index) => (
          <td key={index}>
            {column.footer ? column.footer({ column }) : null}
          </td>
        ))}
      </tr>
      </tfoot>
    </table>
  );
};

export default TableComponent;

enter image description here enter image description here


Solution

  • Since you care about the input type of cell/footer and the output type of accessor, you should consider adding a generic type parameter to Column for that type:

    type Column<T, V> = {
      header: string | (() => ReactElement);
      accessor: (row: T) => V;
      cell?: (prop: V) => ReactElement | string;
      footer?: (prop: V) => ReactElement | string;
    };
    

    Then the value returned by createColumnHelper<T>() should have an accessor method that either accepts a value of type K extends keyof T and produces a Column<T, T[K]>, or it accepts a value of type (row: T) => V for some V and produces a Column<T, V>. That looks like:

    const createColumnHelper = <T,>() => {
      // call signatures
      function accessor<K extends keyof T>(
        accessor: K, column: Omit<Column<T, T[K]>, "accessor">
      ): Column<T, T[K]>;
      function accessor<V>(
        accessor: (row: T) => V, column: Omit<Column<T, V>, "accessor">
      ): Column<T, V>;
    
      // implementation
      function accessor(
        accessor: keyof T | ((row: T) => any), column: Omit<Column<T, any>, "accessor">
      ): Column<T, any> {
        return {
          header: column.header,
          accessor: (row: T) => (typeof accessor === "function" ? accessor(row) : row[accessor]),
          cell: column.cell,
          footer: column.footer,
        };
      }
      return { accessor };
    };
    

    Note that I'm using Omit to easily describe a Column with the accessor property missing. It's not necessary, but saves some writing over {header: ⋯, cell?: ⋯, footer?: ⋯}. This fixes the problem you were having:

    const columns = [
      columnHelper.accessor(
        'firstName', {
        header: 'First Name',
        cell: val => val,
      }),
      columnHelper.accessor(
        'progress', {
        header: 'Progress',
        cell: prop => String(prop.ok), 
      }),
      columnHelper.accessor(row => row, {
        header: () => <span>Age</span>,
        cell: prop => <span>{prop.age}</span>,
      }),
    ];
    

    (and note that I had to fix a bug, since prop.ok is not a string or a ReactElement.)


    That answers the question as asked, but if you do that, you'll run into problems with column.cell ? column.cell(column.accessor(row)) : column.accessor(row) and the like. The only reason why that used to work is because column.cell() accepts any. Now it doesn't. And TS can't verify this as valid because of the multiple utterances of column of a union type. This is ms/TS#30581 more or less, and the fixes for it are well out of scope for this question.

    The easiest middle ground is to have columnHelper.accessor(a, o) require that the input look like Omit<Column<T, T[K]>, "accessor"> or Omit<Column<T, V>, "accessor">, but which returns a Column<T, any>:

    const createColumnHelper = <T,>() => {
      // call signatures
      function accessor<K extends keyof T>(
        accessor: K, column: Omit<Column<T, T[K]>, "accessor">
      ): Column<T, any>;
      function accessor<V>(
        accessor: (row: T) => V, column: Omit<Column<T, V>, "accessor">
      ): Column<T, any>;
    
      // implementation
      function accessor(
        accessor: keyof T | ((row: T) => any), column: Omit<Column<T, any>, "accessor">
      ): Column<T, any> {
        return {
          header: column.header,
          accessor: (row: T) => (typeof accessor === "function" ? accessor(row) : row[accessor]),
          cell: column.cell,
          footer: column.footer,
        };
      }
      return { accessor };
    };
    

    That will make it easy to map over, but the any problem is still there in the output:

    columns[0].cell?.(1) // no error
    

    That should conceivably error with "number is not assignable to {ok?: number, no?: string}". But it doesn't. Oh well, you didn't ask about that, and fixing it would take a bunch more refactoring.

    Playground link to code