Is there any way to use generic when defining props?
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
I tried functional component, it doesn't work either.
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
#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.
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>
)
}
}
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 }]} />
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