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
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.