typescriptvue.js

How to type a dynamic component in a generic vue component?


I have a "calendar" component. This component will consume some data from the caller, and display all the days in a month - if there exists some data for that day, then I want to then display another component. The issue is that my calendar component doesn't know what type of component it should end up displaying in each of the day slot.

I expected that I could just use a Vue3 generic component and say:

// Calendar.vue
<script setup lang="ts" generic="T">
import {computed, DefineComponent, ref} from "vue";
import CalendarDay from "./CalendarDay.vue";

type Props<T> = {
    data: Record<string, T[]>,
    component: DefineComponent<T>,
    labelFn: (length: number, date: Date) => string,
};
const props = withDefaults(
    defineProps<Props<T>>(),
    {
        labelFn: (count, date) => `${count} items on ${date.toLocaleDateString()}`
    }
);

const currDate = new Date();

const currentValue = ref<{year: number, month: number}>(
    {
        year: currDate.getFullYear(),
        month: currDate.getMonth() + 1
    }
);

const dayArray = computed<number[]>(() => {
    const date = new Date();

    const {year, month} = currentValue.value;

    const numbers: number[] = [];

    date.setFullYear(year, month-1, 1);
    while (date.getMonth() == month - 1) {
        numbers.push(date.getDate());

        date.setDate(date.getDate() + 1);
    }

    return numbers;
})

</script>

<template>

    <div aria-live="polite" class="calendar-table">
        <p v-for="i in 7" :key="i" class="calendar-table-header" aria-hidden="true">
            <span class="long-name">
                {{(new Date(2022, 7, i)).toLocaleString(undefined, {weekday: "long"})}}
            </span>
            <span class="short-name">
                {{(new Date(2022, 7, i)).toLocaleString(undefined, {weekday: "short"})}}
            </span>
        </p>

        <CalendarDay
            v-for="day in dayArray"
            :label-fn="props.labelFn"
            :data="props.data"
            :component="props.component"
            :date="new Date(Date.UTC(currentValue.year, currentValue.month - 1, day))"
        />
    </div>
</template>

// CalendarDay.vue
<script setup lang="ts" generic="T">
import { computed, DefineComponent} from "vue";

type Props<T> = {
    data: Record<string, T[]>,
    component: DefineComponent<T>,
    labelFn: (length: number, date: Date) => string,
    date: Date
};
const props = defineProps<Props<T>>();

const dateStr = computed(() => `${props.date.toISOString().split('T')[0]}`);

const value = computed((): T[] => props.data[dateStr.value] ?? []);
const style = computed(() => props.date.getDate() == 1 ? {"grid-column-start": props.date.getDay()} : null)
</script>

<template>
    <div class="day" :class="{'has-items': value.length > 0}" :style="style" :aria-hidden="value.length == 0">
        <div class="day-content" :aria-label="labelFn(value.length, props.date)">
            <p class="date" aria-hidden="true">
                {{ props.date.getDate() }}
            </p>
            <template v-for="val in value">
                <component :is="component" v-bind="val"/>
            </template>

        </div>
    </div>
</template>

Although this works in the browser, the typescript compiler complains:

Type 'DefineComponent<{}, {}, {}, {}, {}, ComponentOptionsMixin, ComponentOptionsMixin, {}, string, PublicProps, Readonly<ExtractPropTypes<{}>>, {}, {}>' is not assignable to type 'DefineComponent<{}>'.ts(2322)
Calendar.vue(7, 5): The expected type comes from property 'component' which is declared here on type '{ data: Record<string, {}[]>; component: DefineComponent<{}>; labelFn: (length: number, date: Date) => string; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'

Argument of type 'T' is not assignable to parameter of type '(CreateComponentPublicInstance<Readonly<T extends ComponentPropsOptions<Data> ? ExtractPropTypes<T> : T>, {}, {}, ComputedOptions, ... 15 more ..., {} & EnsureNonVoid<...>> extends { ...; } ? Props : any) & Record<...>'.  Type 'T' is not assignable to type 'CreateComponentPublicInstance<Readonly<T extends ComponentPropsOptions<Data> ? ExtractPropTypes<T> : T>, {}, {}, ComputedOptions, ... 15 more ..., {} & EnsureNonVoid<...>> extends { ...; } ? Props : any'.ts(2345)
CalendarDay.vue(1, 34): This type parameter might need an `extends CreateComponentPublicInstance<Readonly<T extends ComponentPropsOptions<Data> ? ExtractPropTypes<T> : T>, {}, {}, ComputedOptions, ... 15 more ..., {} & EnsureNonVoid<...>> extends { ...; } ? Props : any` constraint.
CalendarDay.vue(1, 34): This type parameter might need an `extends (CreateComponentPublicInstance<Readonly<T extends ComponentPropsOptions<Data> ? ExtractPropTypes<T> : T>, {}, {}, ComputedOptions, ... 15 more ..., {} & EnsureNonVoid<...>> extends { ...; } ? Props : any) & Record<...>` constraint.

A vue playground of a consumer of the component is here (in this vue playground)

What's the correct way to type this so the compiler can make sure that the data matches the props for that component?


Solution

  • Playground

    The Component type works fine:

    Calendar.vue

    <script setup lang="ts" generic="T extends Record<string, any>">
    import {type Component, computed, ref} from "vue";
    import CalendarDay from "./CalendarDay.vue";
    
    type Props<T> = {
        data: Record<string, T[]>,
        component: Component<T>,
        labelFn: (length: number, date: Date) => string,
    };
    
    ...
    

    So you can use it without any types:

    <script setup lang="ts">
    import Calendar from './Calendar.vue';
    import SessionLink from "./SessionLink.vue";
    
    //type Props = {days: Record<string, Array<InstanceType<typeof SessionLink>['$props']>>};
    
    const props /*: Props */ = {
      days: {
        "2024-08-31": [
            {
              some: 'some',
              data: 'data'
            }
        ]
      }
    }
    
    function label(count: number, date: Date) {
        return `${count} sessions on ${date.toLocaleDateString()}`;
    }
    </script>
    
    <template>
      <Calendar :data="props.days" :component="SessionLink" :label-fn="label" />
    </template>
    

    If you provide wrong types :component will be errored, if you want more fine control, use

    type Props = {days: Record<string, Array<InstanceType<typeof SessionLink>['$props']>>};
    

    so wrong types would be errored in the data.