typescriptvue.jsvuejs3

How to perform a dynamic typing based on Vue.js component props


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>

Solution

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