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

New way to define props and emits options #447

Closed
ktsn opened this issue Sep 12, 2020 · 36 comments
Closed

New way to define props and emits options #447

ktsn opened this issue Sep 12, 2020 · 36 comments

Comments

@ktsn
Copy link
Member

ktsn commented Sep 12, 2020

There is an alternative idea here. Feedback welcome!

Summary

  • To provide props and emits helper function to define corresponding component options in class syntax with type safety.
import { props, emits, mixins } from 'vue-class-component'

// Define component props.
// Props is a class component mixin.
const Props = props({
  value: {
    type: Number,
    required: true
  }
})

// Define component emits.
// Emits is a class component mixin.
const Emits = emits({
  input: (value) => typeof value === 'number'
})

// Use the above options by extending them.
export default Counter extends mixins(Props, Emits) {
  mounted() {
    console.log(this.value)
    this.$emit('input', 10)
  }
}

Motivation

We need additional helpers to define props and emits because there are no corresponding concepts in class syntax. You can define them via @Options decorator, but the problem of the decorator approach is that it does not properly type the component type.

@Options({
  props: ['value'],
  emits: ['input']
})
class MyComp extends Vue {
  mounted() {
    this.value // -> type error
    this.$emit('change', 10) // -> no type error (expecting an error)
  }
}

Because props and emits options modify the existing component types $props and $emit, and has runtime declaration (validator, default, etc.) in addition to types, we have to define them as a super class (mixins).

Details

To provide props and emits function. They receive as the same value as component props and emits options.

import { props, emits } from 'vue-class-component'

// prop names
props(['foo', 'bar'])

// props options object
props({
  count: {
    type: Number,
    required: true,
    validator: (value) => {
      return value >= 0
    }
  }
})

// event names
emits(['change', 'input'])

// emits options object
emits({
  input: (value) => typeof value === 'number'
})

They return a class component mixin so that you can use them with mixins helper function:

import { props, emits, mixins } from 'vue-class-component'

// Define props and emits
const Props = props(['value'])
const Emits = emits(['input'])

// Use props and emits definition by extending them with mixins helper
class MyComp extends mixins(Props, Emits) {
  mounted() {
    console.log(this.value)
    this.$emit('input', 10)
  }
}

As they are just Vue constructors, you can just extend it if there are no other mixins to extend:

import { props } from 'vue-class-component'

// Define props
const Props = props(['value'])

// Just extending Props
class MyComp extends Props {
  mounted() {
    console.log(this.value)
  }
}

Why not decorators?

There has been an approach to define props with ES decorators.

@Component
class App extends Vue {
  @prop({ type: Number }) value
}

But the decorator approach has several issues unresolved yet as stated in abandoned Class API RFC for Vue core. Let's bring them here and take a closer look:

  • Generic argument still requires the runtime props option declaration - this results in a awkward, redundant double-declaration.

    Since decorators do not modify the original class type, we cannot type $props type with them:

    class MyComp extends Vue {
      @prop value: number
    
      mounted() {
        this.value // number
        this.$props.value // *error
      }
    }

    To properly type props, we have to pass a type parameter to the super class which is a redundant type declaration.

    // 1. Types for $props
    interface Props {
      value: number
    }
    
    class App extends Vue<Props> {
      // 2. props declaration
      @prop value: number
    }
  • Using decorators creates a reliance on a stage-2 spec with a lot of uncertainties, especially when TypeScript's current implementation is completely out of sync with the TC39 proposal.

    Although the current decorators proposal is stage 2, TypeScript's decorator implementation (experimentalDecorators) is still based on stage 1 spec. The current Babel decorator (@babel/plugin-proposal-decorators) is based on stage 2 but there is still uncertainty on the spec as the current spec (static decorator) is already different from the original stage 2 proposal (there is a PR to add this syntax in Babel), and also there is another proposal called read/write trapping decorators due to an issue in the static decorators.

    Vue Class Component already uses decorators syntax with @Component (in v8 @Options) but it would be good not to rely on it too much in a new API because of its uncertainty and to reduce the impact of potential breaking changes in the future when we adapt Vue Class Component with the latest spec.

  • In addition, there is no way to expose the types of props declared with decorators on this.$props, which breaks TSX support.

    This is similar to the first problem. The Vue component type has Props type parameter that TSX uses for checking available props type. For example, let's say your component has a type { value: number } as the props type, then the component type is Component<{ value: number }> (this is different from the actual Vue type but you can get the idea from it). TSX knows what kind of value should be passed for the component:

    // MyComp type is `Component<{ value: number }>`
    import { MyComp } from './Counter.vue'
    
    <MyComp value={42} /> // Pass the compilation as the value type matches with the prop type
    <MyComp value={'Hello'} /> // Produce a type error as the value is different from the prop type

    It is impossible to define the props type like the above with decorators because decorators cannot intercept the original class types.

    // MyComp type is fixed with Component<{}>, even though there is @prop decorator!
    class MyComp extends Vue {
      @prop value: number
    }

    The above class component can have the property value of type number but it is not defined as props but just a normal property on the type level. Therefore, the component type is like Component<{}> which TSX cannot know about the props.

@ktsn ktsn added the v8 label Sep 12, 2020
@ktsn ktsn pinned this issue Sep 12, 2020
@ktsn ktsn changed the title To add props and emits helper to define corresponding component options New way to define props and emits options Sep 12, 2020
@Mikilll94

This comment has been minimized.

@zischler
Copy link

@ktsn Am I still able to use @Options somehow with this way? Or how am I going to define watchers for example for components using props defined with mixins?

@ktsn
Copy link
Member Author

ktsn commented Sep 14, 2020

@zischler You can still use @Options as the current way.

@Mikilll94

This comment has been minimized.

@ktsn

This comment has been minimized.

@zischler
Copy link

@ktsn I'm currently trying something like this and it doesn't seem to work. (My linter also gives errors)
Is there something I'm doing wrong? (I can't extend Vue, because I need to extend the props mixin)

const Props = props({
    someProp: {
        type: Number,
        default: 100,
        required: false,
    },
})

@Options<Carousel>({
    watch: {
        this.swipe.hasCursorDown() {
            this.hasCursorDown = !this.hasCursorDown;
        }
    }
})
export default class Carousel extends mixins(Props) {
    swipe = new Swipe();
    hasCursorDown = false;
    .
    .
    .

@ktsn
Copy link
Member Author

ktsn commented Sep 14, 2020

@zischler Please open a new issue with a reproduction for a bug report. Thank you.

@Mikilll94
Copy link

Mikilll94 commented Sep 14, 2020

@ktsn
Thanks. I was struggling with this from a very long time 👍

I didn't know that @Options decorator (or @Component in previous version) accepts a generic type. This is completely not documented and many developers probably don't know about this.

@LifeIsStrange
Copy link

I would like to know what other devs are thinking about the pain points I explain here ?
#456

@nicolidin
Copy link

I agree that :

(Vue 3 : class-component)

const Props = props({
  firstProp: {
    type: Number,
    required: true
  },
  secondProp: {
    type: Number,
    required: true
  }
})

export default Counter extends mixins(Props, Emits) {

}

(Vue 2 class-component + property-decorator)

export default Counter extends Vue {
  @Prop() firstProp!: number;
  @Prop() secondProp!: number;
}

indeed the new Vue version is so much more verbose, less readable, less understandable, and of course less intuitive and less logical for a TypeScript Developer.
`

@ktsn
Copy link
Member Author

ktsn commented Sep 19, 2020

@nicolidin

These snippets are unfair to compare. The equivalent props settings are:

const Props = props({
  firstProp: Number,
  secondProp: Number
})

export default Counter extends Props {}

vs.

export default Counter extends Vue {
  @Prop() 
  firstProp?: number

  @Prop()
  secondProp?: number
}

As for verbosity, I don't think there is much difference between the two approaches. You eventually need the same props options if you want to define your props in detail. Rather, I think the decorator approach is slightly more verbose as we have to write @Prop for every occurrence of prop definition while the mixin approach lets us write props once.

I'm curious what aspects of this proposal makes you feel less readable, less understandable, less intuitive and less logical? By using props helper, the definition of props become a separate block which is clearer where the props definition is. Also, it is property typed with actual props definition while the decorator approach can be wrong as we technically can set arbitrary type for the property of @Prop which the former is more logical for me.

@Mikilll94
Copy link

@ktsn

What will be the equivalent of the following code in vue-class-component v8?

export interface Person {
    firstName: string,
    lastName: string
}

@Prop(Object) readonly person: Person

Because when using this code:

const Props = props({
  person: {
    type: Object,
    required: true
  }
})

@Options({})
export default class HelloWorld extends Props {
  private myData = this.person // this.person has type Record<string, any>
}

Typescript typings of props are not working. this.person has type Record<string, any>

Screenshot 2020-09-20 at 00 57 35

@ktsn
Copy link
Member Author

ktsn commented Sep 20, 2020

@Mikilll94 You can use PropType to annotate complex types. https://v3.vuejs.org/guide/typescript-support.html#annotating-props

export interface Person {
    firstName: string,
    lastName: string
}

const Props = props({
  person: {
    type: Object as PropType<Person>
    required: true
  }
})

export default class HelloWorld extends Props {}

@LifeIsStrange
Copy link

LifeIsStrange commented Sep 20, 2020

As for verbosity, I don't think there is much difference between the two approaches.

So the verbosity is the same for primitive types but as you show, we need this PropType hack for "complex" types , types which are used everywhere actually.

export interface Person {
    firstName: string,
    lastName: string
}

const Props = props({
  person: Person
})

export default class HelloWorld extends Props {}

Why can't this be written ? Can this limitation be alleviated in a followup Vue release ? Is there work going in this direction? I hope it's clear to the Vue.ts devs that this is an API downgrade

@ktsn
Copy link
Member Author

ktsn commented Sep 20, 2020

@LifeIsStrange

Because Person is type while Vue's prop expects value. If you define the Person as a class you can write like that:

export class Person {
    firstName!: string
    lastName!: string
}

const Props = props({
  person: Person
})

export default class HelloWorld extends Props {}

Is there work going in this direction? I hope it's clear to the Vue.ts devs that this is an API downgrade

Well, this PropType approach exists for a long time even in Vue 2 and I don't think it's a downgrade as I already told you in the other issue and mentioned in the proposal - this will unlock advanced type checking feature like props type checking in template and TSX.

@LifeIsStrange
Copy link

Thanks for the explanation, that being said I still think that the downsides outwheights those future improvements.

@nicolidin
Copy link

@ktsn

Ok, I understand what you mean but I think make your component extend Props is not intuitive.
Indeed Props are like parameters and it seems logical that they have to be declared inside the component itself and not outside.
I really think that Props should be composed, it's an injustify used of inheritance (implementation detail leak)

BTW:
I don't mean to disparage your work, I am very grateful for the work you do on integrating typescript into vue.js.
I use vue-class-component and vuex-smart-modules on a lot of projects, without these libraries I'm not sure I would use vue.js.
I fully understand that you have constraints to make this library compatible with the new Vue spec.
I am just disappointed with the changes that this requires.

@ktsn
Copy link
Member Author

ktsn commented Sep 20, 2020

One idea to make it closer to the decorator approach would be:

class AppProps {
  // optional prop
  foo?: string

  // required prop
  bar: string = prop({ required: true })

  // required prop (without runtime check)
  bar2!: string

  // optional prop with default
  baz = prop({ default: 'default value' })
}

class App extends Vue.props(AppProps) {}

Just roughly implemented it and seems working. But I have to make sure if it actually works with practical code/edge cases etc.

This approach doesn't require you to define runtime options (type, required) as same as the decorator approach while the Props type parameter is properly typed.

It still needs to be defined out of the component class but it is the root restriction that we cannot modify the type parameter from in-class definition and I've been thinking of this problem for a long time but no luck. If there is an approach to solve this problem with the definition inside a class, I'm happy to consider that.

@AliLozano
Copy link

Maybe it could be the combination of both..

class App extends Vue() {
    foo = prop<String>({required: true, name:"foo"})
    bar = inject<String>({required: true, name:"bar"})
}

The boilerplate of name is acceptable.

@ktsn
Copy link
Member Author

ktsn commented Sep 24, 2020

Let me summarize the points (if I'm missing something, please add it!):

Definition verbosity

If we re-use the Vue core's canonical props options, we have to specify all value level types (type option, required: true etc.) and we need PropType annotation for complex type that makes the API more verbose.

const Props = props({
  // to get person: Person type...
  person: {
    type: Object as PropType<Person>,
    required: true
  }
})

class App extends Props {}

The decorator (and the alternative approach above) does not require props options value. It is simpler if we do not need runtime validation.

class App extends Vue {
  @Prop
  person: Person
}

Definition in-class vs. out-class

Vue's TSX refers $props type to check if the passed props are correct. To make it work with class component, we have to put the props definition out of class (as far as I can tell).

On the other hand, the decorator allows us to define props inside a class. It is not possible to modify $props type if we have the definition in a class because of how TypeScript works.

@ktsn
Copy link
Member Author

ktsn commented Sep 24, 2020

Thinking of the intuitiveness of in-class definition vs. out-class one, I noticed React's props definition is put outside a class.

interface Props {
  person: Person
}

class App extends React.Component<Props> {}

For me, this way of definition is clear and intuitive enough and I have not heard any negative voice about that as far as I know.

The alternative proposal looks similar to this way:

class Props {
  person!: Person
}

class App extends Vue.props(Props) {}

Defining props individually, then passing it to the super constructor. The difference is React's is type (interface) while the proposed one is value (class). Is it still unintuitive? It would be appreciated to hear everyone's thoughts.

@nicolidin
Copy link

nicolidin commented Sep 24, 2020

Thanks @ktsn for your new proposal.

I saw too that React has Props declaration outside the class component.
Your proposal seems very close to the React declaration and less verbose than the canonical (Vue) props declaration.

The class seems intuitive too.
I have a few questions:

Firstly:

Will it be possible for default props value to be declared and assigned like that:

class Props {
  person = new Person();
}

If not, why? (But it not a big deal cause with the old decorator way we already had to write { default: value } )

Secondly:

With this new proposal API, will the props be properly type-checked at the component call site?

<MyComponent :person="new Person()"/>

So here, will the person prop be type-checked?
Is it this kind of type-checked you call run-time validation?

Lastly:

Does this new proposal API alleviate the need of the PropType annotation for complex type?

BTW:
I think it's your best proposal.

@LifeIsStrange
Copy link

LifeIsStrange commented Sep 24, 2020

I also think this is the best of the current proposals.

@ktsn
Copy link
Member Author

ktsn commented Sep 27, 2020

Thank you for your feedback.

@nicolidin

I'm answering your questions below:

Firstly

I'm introducing prop helper because of the following reasons:

To differentiate required prop type and with-default prop type.

If we allow defining the default value by just assigning it to the property, the property type will be as same as required prop without an initializer:

class Props {
  // Required prop
  // foo is of type string
  foo!: string

  // Has default value
  // bar is of type string too
  bar = 'default value'
}

This is fine when we just use the prop in the component:

class HelloWorld extends Vue.props(Props) {
  mounted() {
    console.log(this.foo) // foo cannot be undefined since it is required
    console.log(this.bar) // bar cannot be undefined because of default value
  }
}

But when we use this component in a parent component, a problem occurs:

<HelloWorld foo="Test" />

The above usage of <HelloWorld> component is correct - we pass the required prop foo, don't have to pass bar as it has the default value. But compile-time validation will fail because it thinks the bar prop is also required because of its type string. To be able to omit bar, we have to make its type string | undefined in the usage of the parent component.

This causes because we don't provide any hint on the type level whether the prop is required or with-default. If we use prop helper, we can provide a type hint for props:

class Props {
  // Required prop
  // foo is of type string as same as the first example
  foo!: string

  // Has default value
  // bar is of type WithDefault<string> so that TypeScript
  // can know the `bar` prop is different from `foo`
  bar = prop({ default: 'default value' })
}

In this way, we can make bar's type string in component while string | undefined in the usage in a parent component.

To provide a way to specify other prop options.

I think we need to provide a way to define props options as same as basic Vue even if we can omit them. For example, we may want to add validator option to validate the prop value in detail. Also, Babel users would still want to specify type, required options.

class Props {
  // Specifying detailed prop options for Vue's runtime props validation
  theme = prop({
    type: String,
    required: true,
    validator: theme => ['primary', 'secondary', 'danger'].includes(theme)
  })
}

Secondly

Let me clarify runtime validation and compile-time validation. Runtime validation is the validation that Vue does that you see on the browser's console. It needs to run your code to validate the props on the browser. On the other hand, compile-time validation is the validation that TypeScript does that you see on your IDE/editor as a red line errors and on CI as compilation errors. It validates the props without running the code.

What I would like to achieve with this proposal is compile-time props validation. So the code:

<MyComponent :person="new Person()"/>

should provide a red line on our IDE/editor if the value passed to the person is mismatched with the defined prop type. Note that we also have to modify Vetur to achieve the compile-time props validation with class component but it will be easier to implement if we properly type $props type with this proposal.

By the way, if we use TSX, the compile-time props validation is already usable. You can try it on the test case in props-class branch.

Lastly

Yes, we don't need to use PropType even with complex types in this approach. e.g.:

interface Person {
  firstName: string
  lastName: string
}

class Props {
  person!: Person // Just specify the Person interface here
}

class App extends Vue.props(Props) {
  mounted() {
    this.person // is of type Person
  }
}

@TiBianMod
Copy link

TiBianMod commented Sep 27, 2020

Thinking of the intuitiveness of in-class definition vs. out-class one, I noticed React's props definition is put outside a class.

interface Props {
  person: Person
}

class App extends React.Component<Props> {}

For me, this way of definition is clear and intuitive enough and I have not heard any negative voice about that as far as I know.

Personally I prefer an interface to define props (makes more sense to me), I love the above example of defining props and I hope we end up with the same or similar implementation like the above.

Related to #416

Available built in properties on this

Since the class constructor is used in setup hook, only following properties are available on this.

  • $props

    • All props are proxied on this as well. (e.g. this.$props.foo -> this.foo)
  • $emit

  • $attrs

  • $slots

Personally I don't like the idea of props to be proxied on this, I really hope we change this behavior and all props to be accessible only through this.$props

const Props = props({
    something: String,
});

export class SomeComponent extends Props {

    something = 'CLASS PROPERTY aka Data';

    public render() {
        return (
            <div>
                {this.$props.something} - {this.something}
            </div>
        );
    }

}
<SomeComponent something="PROP VALUE" />

The result of this component is:

<div>
    PROP VALUE - PROP VALUE
</div>

but where is the value of the something property?

I really hope we change this behavior @ktsn !!!

@ktsn
Copy link
Member Author

ktsn commented Sep 27, 2020

@TiBianMod

Personally I prefer an interface to define props (makes more sense to me), I love the above example of defining props and I hope we end up with the same or similar implementation like the above.

The challenge of Vue's prop is that it is needed to be defined as a value to be able to use it. For example, in the canonical Vue component:

// HelloWorld.js
export default {
  props: {
    foo: String
  },

  mounted() {
    // When it is used as <HelloWorld foo="Hello" bar="World" />
    console.log(this.foo) // Hello
    console.log(this.bar) // undefined
  }
}

In the above example, although the parent component passes values 'Hello' and 'World' as props foo and bar respectively, only foo is available in the HelloWorld component because bar is not defined in the props option.

This is the reason that I think we have to define Props class instead of an interface. An interface only exists on type level, therefore we cannot generate props option object under the hood. In contrast, a class exists on both type level and as a value, we can generate the option with it under the hood.

There was a proposal to make it optional but it was dropped later because of issues regarding attribute fallthrough behavior.

I'm not sure if there is another way to make it closer to interface under this restriction. But I'm happy to hear an idea if any.

Personally I don't like the idea of props to be proxied on this, I really hope we change this behavior and all props to be accessible only through this.$props

Unfortunately, this is something we cannot change in Vue Class Component as it's Vue's behavior.

@nicolidin
Copy link

Thanks @ktsn again for your answer describing all the points I wrote!

I'm happy to see this proposal and to understand why it has to be a "little bit more verbose" for runtime Vue validation!
I agree with the three points you wrote.
I understand too that we can't use an Interface for Prop definition.

When this new proposal API will be on production so we could try it?

Thanks again for putting consideration on our feedback and for your investment in integrating typescript into Vue as well as possible.

@ktsn
Copy link
Member Author

ktsn commented Sep 28, 2020

@nicolidin I'm going to release the next beta including the improved approach this weekend so that you can try it in your code base. If you mean the official release of v8.0.0, I cannot say a specific date for that but I wish it will be by the end of Oct.

@TiBianMod
Copy link

@ktsn
I was playing yesterday locally with the props-class branche and I can say that I really liked. On IDE the autocompletion the error message, all on point, nice :)

Give as please one example how you can use Props and Mixins together.

One thing that I noticed, is that with this implementation I lost the Insert required attribute from my IDE and the error message is a little big, hehe :)

Normal use... No 'Insert required attribute' :(

interface Person {
    name: string;
    age: number;
}

export class Props {
    person = prop<Person>({ required: true }); // for the moment you need this for the browser, ignored :(
}

export class Comp extends Vue.props(Props) {

    public render() {
        return (
            <button>SOMETHING....</button>
        );
    }

}

Screen Shot 2020-09-28 at 17 12 12

This example is only to inform the IDE for the available props, and to show you the image for what i mean...

interface Person {
    name: string;
    age: number;
}

export class Props {
    person = prop<Person>({ required: true });
}

class Component<P> extends Vue {
    $props!: P; // Inform the IDE for the available props
}

export class Comp extends Component<Props> {

    public render() {
        return (
            <button>SOMETHING....</button>
        );
    }

}

Screen Shot 2020-09-28 at 17 13 52

Also thank you very much for the quick response on my previous post.

@ktsn
Copy link
Member Author

ktsn commented Sep 28, 2020

I got the same info from both types (the printed type is, of course, different because vue-class-component adds extra types allowed in JSX). I suppose your IDE handles some special cases of JSX and prints the dedicated error message? I guess there is nothing we can do if that is the case. 🤔

@TiBianMod
Copy link

Basically the Insert required attribute it auto insert the required props, in this case something like person={}.
-this option don't exist on VSCode, at least not by default, i use PHPStorm (i have the same action available for vue, tsx, jsx etc..)

see the comments please...

class Component<P> extends Vue {
    // all the informations coming from here, in this case all the info/types from class `Props`
    // this is the reason why the `Insert required attribute` is available, and the printed type is shorter
    $props!: P; <========= $props
}

export class Comp extends Component<Props> {

    public render() {
        return (
            <div>SOMETHING....</div>
        );
    }

}

but is all good, we going to find later solution for this :)

@robob4him
Copy link

From the class perspective, is there a reason I'd define my constructor args in a parent class and then extend that parent for the implementation? That's the hardest part for me to embrace this kind of solution.

class Foo_vars {
  constructor(protected bar: string) { ... }
}

class Foo extends Foo_vars {
  bar() { this.bar }
}

@ktsn
Copy link
Member Author

ktsn commented Sep 29, 2020

I already described why we have to use inheritance in this thread. #447 (comment)

@robob4him
Copy link

@ktsn, thanks! I was having trouble navigating the different scenarios this solution is trying to resolve.

Quick question - you seem to be emphasizing that decorators will not be a solution going forward - is that true or are you only talking about @component and @prop?

@ktsn ktsn unpinned this issue Oct 4, 2020
@ktsn
Copy link
Member Author

ktsn commented Oct 4, 2020

I've written details of the alternative approach at #465. Thank you for everyone's feedback!

@serkon
Copy link

serkon commented Jul 30, 2021

When i try to import import { emits, props, Options, Vue } from 'vue-class-component'; getting bellow error?

Are you sure emits decorator or props decorator is exported. I tried vue-class-component with "^7.2.6" and ^8.0.0. There are no exported member:

Module '"vue-class-component"' has no exported member 'emits'.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

9 participants