I'm trying to create reusable data table component with dynamic column formatting and typing in Vue 3. This table accepts data rows and column definitions, including components to display this column as well as value getters.
<script setup lang="ts">
import type { Component } from 'vue';
export interface IColumn<T = any> {
id: number;
/**
* Vue component that will be used to display the column.
* This component always has a `data` property.
*/
format?: Component;
/**
* Function returning the value that will be passed
* to the `format` component via `data` property.
*/
value?: (row: T) => unknown; // ‼️ I need a corresponding return type here
}
defineProps<{
cols: IColumn[];
rows: any[];
}>();
</script>
<template>
<table>
<tbody>
<tr v-for="row in rows" :key="row.id">
<td v-for="col in cols" :key="col.id">
<component :is="col.format" :data="col.value(row)" />
</td>
</tr>
</tbody>
</table>
</template>
❓ I'm struggling with a proper return type for the value
function.
Currently I'm using unknown
, but it should be based on the data
prop accepted by the format
component. Seems that this should involve some deep TypeScript magic, but I'm unable to get anywhere.
Here is the usage example to make it a little bit more clear:
<script setup lang="ts">
// Omitted imports go here...
interface User {
id: number;
name: string;
created_at: string;
expire_at: string;
}
const rows: User[] = [
{ id: 1, name: 'John', created_at: '2022-10-10', expire_at: '2022-10-11' },
{ id: 2, name: 'Jane', created_at: '2022-07-10', expire_at: '2022-11-11' },
{ id: 3, name: 'Anna', created_at: '2022-10-07', expire_at: '2023-11-10' },
];
const cols: IColumn<User>[] = [
{
label: 'ID',
format: FNumber,
value: (user) => user.id,
},
{
label: 'Name',
format: FString,
value: (user) => user.name,
},
{
label: 'Activity',
format: FDateRange,
value: (user) => ({
dateFrom: user.created_at,
dateTo: user.expire_at,
}),
},
];
Here is the example of FDateRange.vue
that can be used as IColumn
's format
<script setup lang="ts">
defineProps<{
data: {
dateFrom: Date;
dateTo: Date;
};
}>();
</script>
<template>
<span>{{ (new Date(data.dateFrom)).toLocaleDateString() }}</span>
–
<span>{{ (new Date(data.dateTo)).toLocaleDateString() }}</span>
</template>
Let's tick off the easy part first, a utility type to extract the data props from your component:
import type { ComponentInstance } from 'vue'
type ComponentProps<T> = ComponentInstance<T>['$props']
type DataProp<T> = ComponentProps<T> extends { data: infer P } ? P : never
The hard part though is that your IColumn
type really has two independent generic type params, one for the row data type, which you want it to be passed in manually, and one for the component type, which you want it to be inferred.
Basically this is the idea of "currying" in functional programming. TypeScript does not support such feature with pure "type-only" syntax. So you have 2 options, either you pass in both generic type params manually, or you have to actually use a JavaScript helper construct (that leaves a small runtime footprint) to implement the currying behavior.
interface IColumn<T = any, C extends Component = Component> {
label: string
format: C
value: (row: T) => DataProp<C>
}
// need to manually specify the component type as union of all possible components
const cols: IColumn<User, typeof FDateRange | typeof FNumber>[] = [
{
label: 'Activity',
format: FDateRange, // C is inferred as typeof FDateRange
value: (user) => ({
dateFrom: new Date(user.created_at),
dateTo: new Date(user.expire_at),
}),
},
{
label: 'User ID',
format: FNumber, // C is inferred as typeof FNumber
value: (user) => user.id,
},
]
// Generic factory function that curries T, and allows C to be inferred later
// It's useless as it returns config as-is, but necessary to infer the type
function createColHelper<T>() {
return function <C extends Component = Component>(config: {
label: string
format: C
value: (row: T) => DataProp<C>
}) {
return config
}
}
const userCol = createColHelper<User>();
const cols = [
userCol({
label: 'Activity',
format: FDateRange, // C is inferred as typeof FDateRange
value: (user) => ({
dateFrom: new Date(user.created_at),
dateTo: new Date(user.expire_at),
}),
}),
userCol({
label: 'User ID',
format: FNumber, // C is inferred as typeof FNumber
value: (user) => user.id, // FNumber expects data: number
})
];