Subscribe on changes!

Is there any way to use generic when defining props?

avatar
Jan 26th 2021

What problem does this feature solve?

Robust prop definition. See the following pic.

What does the proposed API look like?

I have not come up with it.

However in react it does work.

https://codesandbox.io/s/epic-knuth-lffi0?file=/src/App.tsx:0-785

image

I tried functional component, it doesn't work either.

image

avatar
Feb 1st 2021

Vue handles props differently from react, in vue a prop can have runtime validation.

I have this https://github.com/vuejs/vue-next/pull/3049 PR to introduce a similar way to pass the type to props, but this will still require you to define the props object.

I might misunderstand your issue, please clarify

avatar
Feb 1st 2021

#3049

I know vue can do a runtime validation. What I need is to make different prop got connected by generic. For example you can create a select component with props:

{
  value: string | string[]
  onChange: (value: string) => void | (value: string[]) => void
}

But if I do this there would be a lot of problem when handling prop internally and do prop type check. Conceptually the best way is to specify the prop like this

<T extends string | string[]>{
  value: T
  onChange: (value: T) => void
}

React component libraries do a lot like this.

avatar
Oct 11th 2021

Is there any other place I can follow discussions/progress about this one?

avatar
Feb 3rd 2022

If you only need generic props then you can use this tutorial: https://logaretm.com/blog/generically-typed-vue-components/ It worked for me BUT: It does not help in creating generic slots.

If anyone has solution to create generic component with both generic props and generic slots, please, share your ideas.

avatar
Feb 3rd 2022

I've a hacky workaround (with setup only), it works for tsx, ts, template. However I don't recommend it.

I think it isn't a good idea to implement a generic component before vue officially support it.

import { h, OptionHTMLAttributes, SelectHTMLAttributes, VNodeChild } from 'vue'

/**
 * tsconfig:
 *
 * "jsx": "react",
 * "jsxFactory": "h",
 * "jsxFragmentFactory": "Fragment",
 *
 */
declare global {
  // eslint-disable-next-line @typescript-eslint/no-namespace
  namespace JSX {
    interface ElementChildrenAttribute {
      $slots: {}
    }
    interface IntrinsicElements {
      select: { $slots: any } & SelectHTMLAttributes // 不加这个的话 vue 内置 jsx 元素没有 $slots
      option: { $slots: any } & OptionHTMLAttributes // 不加这个的话 vue 内置 jsx 元素没有 $slots
    }
  }
}

interface SelectProps<T extends string | number> {
  value?: T
  options?: Array<{ label: string, value: T }>
}

interface SelectSlots<T extends string | number> {
  option?: (option: { label: string, value: T }) => VNodeChild
}

// 关键步骤在这里
const _Select = class <T extends string | number = string | number> {
  $props: SelectProps<T> & { $slots?: SelectSlots<T> } = null as any
  $slots?: SelectSlots<T>
  constructor () {
    return this as any
  }

  setup (
    props: SelectProps<T>,
    { slots }: { slots: SelectSlots<T> }
  ): () => VNodeChild {
    return () => {
      return (
        <select value={props.value}>
          {props.options?.map((option) => {
            return slots.option ? (
              slots.option(option)
            ) : (
              <option value={option.value}>{option.label}</option>
            )
          })}
        </select>
      )
    }
  }
}

function resolveRealComponent<T> (fakeComponent: T): T {
  return {
    setup: (fakeComponent as any).prototype.setup
  } as any
}

const TestSelect = resolveRealComponent(_Select)

const vnode1 = h(TestSelect, {
  value: '123',
  options: [{ label: '1243', value: 123 }]
})

const vnode2 = (
  <TestSelect value={123} options={[{ label: '123', value: 134 }]}>
    {{
      option: ({ label, value }) => {
        return 1
      }
    }}
  </TestSelect>
)

console.log(vnode1, vnode2)

export { TestSelect }

// Select<Option, Clearable, LabelField, ValueField>
// Cascader<Option, Clearable, LabelField, ValueField, ChildrenField>
avatar
Feb 4th 2022

@07akioni maybe we can use class component + tsx. and it can solve all the pain points. see https://agileago.github.io/vue3-oop/

example

import { type ComponentProps, Mut, VueComponent } from 'vue3-oop'
import type { VNodeChild } from 'vue'

interface GenericCompProp<T> {
  data: T[]
  slots?: {
    itemRender(item: T): VNodeChild
  }
}
class GenericComp<T> extends VueComponent<GenericCompProp<T>> {
  static defaultProps: ComponentProps<GenericCompProp<any>> = ['data']

  render() {
    const { props, context } = this
    return (
      <>
        <h2>GenericComp</h2>
        <ul>{props.data.map(k => context.slots.itemRender?.(k))}</ul>
      </>
    )
  }
}

export default class HomeView extends VueComponent {
  @Mut() data = [1, 2]

  render() {
    return (
      <div>
        <h1>home</h1>
        <GenericComp
          data={this.data}
          v-slots={{
            itemRender(item) {
              return <li>{item}</li>
            },
          }}
        ></GenericComp>
      </div>
    )
  }
}

image

avatar
Feb 17th 2022

We need to use classes to solve this, classes are not planned to be supported.

Another way to do this (hacky overhead), would be doing something like:

import { Component, defineComponent } from 'vue'

function genericFunction<G extends { new(): { $props: P } }, P, T extends Component>(f: () => T, c: G): G & T {
    return f() as any
}

declare class TTTGenericProps<T extends { a: boolean }>  {
    $props: {
        item: T,
        items?: T[]
    }
}

const TTT = genericFunction(<T extends { a: boolean }>() => defineComponent({
    props: {
        item: Object as () => T,
        items: Array as () => T[],
    },

    emits: {
        update: (a: T) => true
    },

    setup(props, { emit }) {
        // NOTE this should work without casting
        props.items?.push(props.item! as T)


        // @ts-expect-error not valid T
        props.items?.push(1)

        props.items?.push({ a: false } as T)

        // @ts-expect-error
        props.items?.push({ b: false } as T)


        emit('update', props.items![0])
        // @ts-expect-error
        emit('update', true)
    }
// casting undefined to prevent any runtime cost
}), undefined as any as typeof TTTGenericProps);

; <TTT item={{ a: true, b: '1' }} items={[{ a: false, b: 22 }]} />

// @ts-expect-error
; <TTT item={{ aa: true, b: '1' }} items={[{ a: false, b: 22 }]} />

playground

avatar
Feb 17th 2022

For anyone interested I found the following solution:

I use GlobalComponentConstructor type from Quasar framework:

// Quasar type
type GlobalComponentConstructor<Props = {}, Slots = {}> = {
  new (): {
    $props: PublicProps & Props
    $slots: Slots
  }
}

interface MyComponentProps<T> {
  // Define props here
}

interface MyComponentSlots<T> {
  // Define slots here
}

type MyComponentGeneric<T> = GlobalComponentConstructor<MyComponentProps<T>, MyComponentSlots<T>>;

defineComponent({
  name: "another-component",
  components: {
    "my-component-generic-boolean": MyComponent as unknown as MyComponentGeneric<boolean>,
    "my-component-generic-string": MyComponent as unknown as MyComponentGeneric<string>
  }
}

Volar and vue-tsc recognize the above pattern. As a result I get type safety both for props and slots.

The downside is that I need to define Slots and Props interfaces. However, Quasar does the same.

It would be ideal if Vue added defineComponent<Slots, Props> version of defineComponent that would validate my Slots and Props interfaces.

avatar
Apr 3rd 2022

I will add an example of my ideal usage pattern, and explain what I am currently doing to work around this. +1.

types.d.ts

export interface Image {
  filename?: string;
  src: string;
}

export interface PhotoImage extends Image {
  fValue?: number;
  shutterSpeed?: number;
  iso?: number;
}

ImageGallery.vue

<template>
  <div class="image-gallery">
    <!-- ..stuff.. (display images list) -->

    <section v-if="$scopedSlots['selected-image-viewer']">
      <slot name="selected-image-viewer" :selected-image="selectedImage"></slot>
    </section>
  </div>
</template>

<script lang="ts">
import { Image, PhotoImage } from "@/types";
import { defineComponent, PropType, Ref, ref } from "@vue/composition-api";

export default defineComponent({
  props: {
    images: {
      // currently, this is not possible? And so I just use `PropType<Image>` instead
      type: PropType<T extends Image>,
     default: [],
  },

  setup(props) {
    const selectedImage: Ref<null | T> = ref(null);  // currently `Ref<null | Image>`
    // ... logic that sets a "selected" image to one of the images on click

    return {
      selectedImage,
    };
  },
});
</script>

PhotosPage.vue

<template>
  <div id="my-page">
    <ImageGallery :images="images">
      <template #selected-image-viewer="{ selectedImage }">
        <!-- So here, selectedImage should be of type `PhotoImage`, because we gave `PhotoImage[]`. But,
             right now it is type Image, and doesn't have the fields of PhotoImage. As a workaround I cast
             to `any` within the component and expose that for the slot instead (no type safety).
        -->
      </template>
    </ImageGallery>
  </div>
</template>

<script lang="ts">
import { PhotoImage } from "@/types";
import { defineComponent, Ref, ref } from "@vue/composition-api";
import ImageGallery from "@/components/ImageGallery.vue";

export default defineComponent({
  components: {
    ImageGallery,
  },

  setup() {
    const images: Ref<PhotoImage[]> = ref([]);
    // set images somehow

    return {
      images,
    }
  },
});
</script>
avatar
Apr 10th 2022

The solutions above seems all not work with scopedSlot props (intellisense scopedSlots types by props types)?

I found out that now volar can support generics for functional component, is it possible to use functional component to do this?

const Component = <T>(props: Props<T>, context: SetupContext) => {
    return h('div');
}

Now, use this component can get generic props work, but will lost the scoped slots type.

My idea is to extend the functional component formats like this: add slots to SetupContext, so volar can intellisense both generic props type and scoped slots props type?

const Component = <T,  P = Props<T>, S = Slots<T>, E = Events<T>>(props: P, context: SetupContext<E, S>) => {
    return ('div'),
}
avatar
Jul 23rd 2022

I was able to get volar to properly see props/slots, but it required manually defining the Slots and using a wrapper component.

The <NoGenerics> component is implemented normally

<template>
  <div>
    <template v-if="isLoading">
      <slot name="loading">Loading</slot>
    </template>
    <template v-else-if="isError">
      <slot name="error" v-bind="{ error }">
        {{ error }}
      </slot>
    </template>
    <template v-else>
      <slot name="success" v-bind="{ data }">
        <pre>{{ JSON.stringify(data, null, 2) }}</pre>
      </slot>
    </template>
  </div>
</template>

<script setup lang="ts">
import type { VNode } from 'vue-demi'

export interface Props<D, E> {
  query: {
    isLoading: Ref<boolean>
    isError: Ref<boolean>
    error: Ref<E>
    data: Ref<D>
  }
}

export interface Slots<D, E> {
  loading?: () => Array<VNode> | undefined
  error?: (context: { error: E }) => Array<VNode> | undefined
  success?: (context: { data: D }) => Array<VNode> | undefined
}

const props = defineProps<Props<unknown, unknown>>()

const { isLoading, isError, error, data } = props.query
</script>

And then the <YesGenerics> is the one that gets used in the rest of the project:

<script lang="ts">
import NoGenerics from './NoGenerics.vue'
import type { Props, Slots } from './NoGenerics.vue'

type WithGenerics = new <D, E>(props: Props<D, E>) => {
  $props: Props<D, E>
  // Use `$slots` for vue 3
  $scopedSlots: Slots<D, E>
}

export default NoGenerics as WithGenerics
</script>
avatar
Oct 8th 2022

I was able to get volar to properly see props/slots, but it required manually defining the Slots and using a wrapper component.

The <NoGenerics> component is implemented normally

Thank you @achaphiv. I used your approach to make my component handle generic properties and it worked perfectly with Volar 0.40.13.

Unfortunately, once I upgraded to Volar 1.0.0, the Vue props no longer inferred their types from the values assigned. When I hover over the prop it now shows MyComponent<unknown>.myProp.

I've had to downgrade Volar back to 0.40.13 for it to work properly in VS Code.

I should note that in Volar 1.0.0, the code still compiles with vue-tsc but it fails with type-checking.

avatar
Dec 28th 2022

I just want to mention this video which outlines (and solves for React) this exact issue - https://www.youtube.com/watch?v=hBk4nV7q6-w