vuejs/core

Is there any way to use generic when defining props?

Closed this issue · 13 comments

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

pikax commented

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

I have this #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

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

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

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.

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>

@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

pikax commented

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

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.

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>

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'),
}

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>

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.

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