2

Background

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.

Data Table

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

Question

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.

Usage Example

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,
    }),
  },
];

Format Component Example

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>

1 Answer 1

1

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.

Option 1: manually pass in both type params

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,
  },
]

Option 2: JS runtime construct that impls type param currying

// 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
  })
];
Sign up to request clarification or add additional context in comments.

1 Comment

Exactly what I needed, thanks a lot!

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.