Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Is there any way to use generic when defining props? #3102

Closed
07akioni opened this issue Jan 26, 2021 · 13 comments · May be fixed by #3682
Closed

Is there any way to use generic when defining props? #3102

07akioni opened this issue Jan 26, 2021 · 13 comments · May be fixed by #3682
Labels

Comments

@07akioni
Copy link
Contributor

07akioni commented Jan 26, 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

@pikax
Copy link
Member

pikax commented Feb 1, 2021

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

@07akioni
Copy link
Contributor Author

07akioni commented Feb 1, 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.

@oswaldofreitas
Copy link

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

@iliubinskii
Copy link

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.

@07akioni
Copy link
Contributor Author

07akioni commented Feb 3, 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>

@agileago
Copy link

agileago commented Feb 4, 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

@pikax
Copy link
Member

pikax commented Feb 17, 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

@iliubinskii
Copy link

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.

@aentwist
Copy link

aentwist commented Apr 3, 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>

@ccqgithub
Copy link

ccqgithub commented Apr 10, 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'),
}

@achaphiv
Copy link

achaphiv commented Jul 23, 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>

@colinj
Copy link

colinj commented Oct 8, 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.

@hymair
Copy link

hymair commented Dec 28, 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

IAmSSH pushed a commit to IAmSSH/core that referenced this issue May 14, 2023
BREAKING CHANGE: The type of `defineComponent()` when passing in a function has changed. This overload signature is rarely used in practice and the breakage will be minimal, so repurposing it to something more useful should be worth it.

close vuejs#3102
@github-actions github-actions bot locked and limited conversation to collaborators Sep 12, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Projects
None yet
Development

Successfully merging a pull request may close this issue.