I can't seem to figure out a dynamic way to have dataItem
always be the same type as the value of the field
property.
In the following example dataItem
is always string | Date | number
instead of being the specific one depending on what the field
property is.
interface TableColumn<D> {
field: keyof D
renderCell: (dataItem: D[keyof D]) => ReactNode
}
interface Data {
foo: string
bar: Date
fiz: number
}
const data: Data = {
foo: "my foo",
bar: new Date(),
fiz: 32
}
const tableColumn: TableColumn<Data> = {
field: "foo",
renderCell: (dataItem) => dataItem // here dataItem should be string only
}
const tableColumn1: TableColumn<Data> = {
field: "bar",
renderCell: (dataItem) => dataItem // here dataItem should be Date only
}
const tableColumn2: TableColumn<Data> = {
field: "fiz",
renderCell: (dataItem) => dataItem // here dataItem should be number only
}
CodePudding user response:
An interface wouldn't work this way; you could possibly make it more generic like TableColumn<D, K>
where K
is a particular key type in keyof D
, but then you'd have to specify K
everywhere.
Instead I would make TableColumn<D>
a distributive object type as coined in microsoft/TypeScript#47109 that evaluates to a union of the individual types corresponding to each K
in keyof T
. The distributive object type is a mapped type which turns every property value K
from keyof D
into one holding the desired type corresponding to that property key, and which is then immediately indexed into with keyof D
. Like this:
type TableColumn<D> = { [K in keyof D]-?:
{
field: K,
renderCell: (dataItem: D[K]) => ReactNode
}
}[keyof D]
Here we generate the mapped type { [K in keyof D]-?: { field: K, renderCell: (dataItem: D[K]) => ReactNode }}
, which becomes for Data
something like
{
foo: { field: "foo"; renderCell: (dataItem: string) => ReactNode }
bar: { field: "bar"; renderCell: (dataItem: Date) => ReactNode }
fiz: { field: "fiz"; renderCell: (dataItem: number) => ReactNode }
}
Note that the mapping modifier -?
just makes sure that any optional properties in D
become required in the mapped type, since we don't want any undefined
s floating around here.
Then we index into this via {...}[keyof D]
, which turns that mapped type into a union of its value types, ultimately producing:
type TCD = TableColumn<Data>
/* type TCD = {
field: "foo";
renderCell: (dataItem: string) => ReactNode;
} | {
field: "bar";
renderCell: (dataItem: Date) => ReactNode;
} | {
field: "fiz";
renderCell: (dataItem: number) => ReactNode;
} */
That's the union type you want; in fact, it's a discriminated union where field
is the discriminant property. That lets the compiler infer the type of renderCell
from the type of field
, and thus gives you the desired contextual typing on renderCell
's unannotated callback parameter:
const tableColumn: TableColumn<Data> = {
field: "foo",
renderCell: (dataItem) => dataItem
// (parameter) dataItem: string
}
const tableColumn1: TableColumn<Data> = {
field: "bar",
renderCell: (dataItem) => dataItem
// (parameter) dataItem: Date
}
const tableColumn2: TableColumn<Data> = {
field: "fiz",
renderCell: (dataItem) => dataItem
// (parameter) dataItem: number
}