Search code examples
typescriptgenerics

Problem with TypeScript generic interface method


I'm trying to create inteface with generic method. I don't know how to specify generic type when using it in list of objects.

export type Data = Array<object>
export interface Column {
    name: string
    index: string
    columns?: Column[]
    formatter?: <T>(cell: T) => T
}

export const listOfEmployeesColumns: Column[] = [
    {
        name: "Salary",
        index: "salary",
        formatter: (cell: number) =>  cell * 0.23
    },
    {
        name: "Hire date",
        index: "hire_date",
        formatter: (cell: string) =>  cell.replace("-", "/")
    }
]

export const listOfEmployees = [
    {
        "salary": 7000,
        "hire_date": "2020-01-15"
    },
    {
        "salary": 11000
        "hire_date": "2018-07-01"
    }
]

const Table = (
    { rows, columns, index = false, handleCreate = undefined }:
        { rows: Data, columns: Column[], index?: boolean, handleCreate?: () => void }
) => {
    return {columns.map(column => {
       return column.formatter !== null ? <td>column.formatter(rows[column.index])</td> : <td>rows[column.index]</td>
    })
}

<Table 
    rows={listOfEmployees} 
    columns={listOfEmployeesColumns}
/>

In this case I got this error:

Type '(cell: string) => string' is not assignable to type '<T>(cell: T) => T'.
Types of parameters 'cell' and 'cell' are incompatible.
Type 'T' is not assignable to type 'string'.

How to specify generic type of formatter method of Column interface in list of Column objects


Solution

  • The only way this could work is if Column is generic in the type T of the data it represents (the parameter type and the return value of formatter). You don't want formatter to be a generic method. That would mean callers can choose the type argument. Since you need the implementer to choose the type argument, that means Column itself needs to be generic.

    Additionally, according to your use case, it is also essentially generic in the type K of the index property, since that property must match up with a key of the relevant data... and T must the property type at that key.

    With this in mind, here's a possible definition for Column:

    interface Column<K extends PropertyKey, T> {
        name: string
        index: K
        columns?: T extends object ? readonly ColumnForProps<T>[] : never;
        formatter?: (cell: T) => T
    }
    
    type ColumnForProps<T extends object> =
        { [K in keyof T]-?: Column<K, T[K]> }[keyof T]
    

    Note that this is effectively a recursive definition. The columns property is a conditional type. If the type T is itself an object, then columns should be an array of Column objects for each property of T. (I'm using a readonly array, which is more permissive than a mutable array.) If T is not an object then columns is an optional property of the impossible never type, meaning that if T isn't an object, you shouldn't have a defined columns property for Column<K, T>.

    The type ColumnForProps<T> is a distributive object type (as coined in microsoft/TypeScript#47109) which becomes the union of Column<K, T[K]> for each K in keyof T. So if T is {a: A, b: B}, then ColumnForProps<T> is Column<"a", A> | Column<"b", B>.


    That also means Table must be generic in the type T of the elements of the rows property, like this:

    const Table = <T extends object>(
        { rows, columns, index = false, handleCreate = undefined }: {
            rows: readonly T[],
            columns: readonly ColumnForProps<T>[],
            index?: boolean,
            handleCreate?: () => void
        }
    ) => (null!); // <-- not worried about implementation
    

    Note that I've removed the implementation since we don't care about it here, and the real implementation should presumably be recursive, and I don't want to digress.

    So, when you call Table, TypeScript should infer T from the element type of the rows property, and then use it to require that columns is an array of ColumnForProps<T> objects.

    Let's test that out:

    const listOfEmployeesColumns = [
        {
            name: "Salary",
            index: "salary",
            formatter: (cell: number) => cell * 0.23
        },
        {
            name: "Hire date",
            index: "hire_date",
            formatter: (cell: string) => cell.replace("-", "/")
        }
    ] as const;
    
    const listOfEmployees = [
        {
            "salary": 7000,
            "hire_date": "2020-01-15"
        },
        {
            "salary": 11000,
            "hire_date": "2018-07-01"
        }
    ];
    
    <Table
        rows={listOfEmployees} // okay
        columns={listOfEmployeesColumns} // okay
    />
    

    That works without error. Note that I had to use a const assertion on the initializer for listOfEmployeesColumns, in order for TypeScript to keep track of the literal types of the index properties. Otherwise TypeScript would infer string which isn't enough information to verify that listOfEmployeesColumns matches listOfEmployees.

    If we write the list inline then we don't need a const assertion because TypeScript knows to pay attention to the literal types of index:

    <Table
        rows={listOfEmployees}
        columns={[
            {
                name: "Salary",
                index: "salary",
                formatter(cell) { return cell - 1 }
            },
            {
                name: "Hire date",
                index: 'hire_date',
                formatter(cell) { return cell.toUpperCase() }
            }
        ]}
    />
    

    You can see that TypeScript will complain if you try to use an index it doesn't expect:

    <Table
        rows={listOfEmployees}
        columns={[
            { name: "Salary", index: "salary" },
            { name: "Hire date", index: 'hire_date' },
            { name: "Oops", index: 'oops' } // error!
            //              ~~~~~ <-- Type '"oops"' is not 
            // assignable to type '"salary" | "hire_date"'.
        ]}
    />
    

    Or if you give it a bad formatter:

    <Table
        rows={listOfEmployees}
        columns={[
            { name: "Salary", index: "salary" },
            {
                name: "Hire date", index: 'hire_date',
                formatter(cell: number) { return cell + 1 }
            }, // error!
            // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
            // Types of property 'formatter' are incompatible.
        ]}
    />
    

    That should hopefully justify the need for Column to be generic in the type of index also, since it is precisely that property which governs the expected type of formatter.

    Finally, because Column is recursive, let's see what happens if rows contains nested objects:

    <Table
        rows={[{ a: { b: "abc" } }, { a: { b: "def" } }]}
        columns={[
            {
                index: "a", name: "A", columns: [
                    { index: "b", name: "B", formatter(cell) { return cell.toLowerCase() } }
                ]
            }
        ]}
    />
    

    Looks good. The type of the rows elements is {a: {b: string}}, and you can see that TypeScript expects the columns property of the index: "a" element to be an index: "b" column, where formatter expects and returns a string value.

    Playground link to code