core icon indicating copy to clipboard operation
core copied to clipboard

Is there any way to use generic when defining props?

Open 07akioni opened this issue 3 years ago • 12 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

07akioni avatar Jan 26 '21 14:01 07akioni

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

pikax avatar Feb 01 '21 11:02 pikax

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

07akioni avatar Feb 01 '21 14:02 07akioni

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

oswaldofreitas avatar Oct 11 '21 15:10 oswaldofreitas

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.

iliubinskii avatar Feb 03 '22 15:02 iliubinskii

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 avatar Feb 03 '22 15:02 07akioni

@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

agileago avatar Feb 04 '22 04:02 agileago

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

pikax avatar Feb 17 '22 16:02 pikax

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.

iliubinskii avatar Feb 17 '22 17:02 iliubinskii

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>

aentwist avatar Apr 03 '22 15:04 aentwist

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

ccqgithub avatar Apr 10 '22 06:04 ccqgithub

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>

achaphiv avatar Jul 23 '22 08:07 achaphiv

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.

colinj avatar Oct 08 '22 03:10 colinj

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

hymair avatar Dec 28 '22 06:12 hymair