Vue 2 is not type-friendly. Vue Class Component and Vue Property Decorator are libraries that help improve this, but there is still a disconnect in templates when passing properties to components.
Take, for example, the following Vue component:
// SimpleCounter.vue
<template>
{{ value }}
</template>
<script lang="ts">
@Component
export default class SimpleCounter extends Vue {
private value: number = 0;
@Prop() step!: number;
@Prop({ default: 0 }) startAt!: number;
created() {
this.value = this.startAt;
}
increment() {
this.value += this.step;
}
}
</script>
And the following parent component (notice the error!):
// App.vue
<template>
<!-- There should be an error here, "potato" isn't a number! -->
<simple-counter step="potato">
</template>
Vue 2 wouldn't have a way to prevent this error at compile time.
At Flare, we have been working around this for a while by:
- Declaring an interface associated with every component, defining the required properties.
- Using computed properties to have type-checking on the interface.
- Passing the properties to the component using the v-bind directive.
// SimpleCounter.ts
// This file exports an interface that is used by all Simplecounter users.
// The interface has to be defined by hand, but at least it allows for
// validating that all users of the component are updated when we update
// the interface.
export interface SimpleCounterProps {
step: number;
startAt?: number;
};
// App.vue
<template>
<simple-counter v-bind="simpleCounterProps" />
</template>
<script>
@Component
class App extends Vue {
get simpleCounterProps(): SimpleCounterProps {
// We couldn’t make a mistake here.
// Typescript would warn us.
return {
step: 10,
};
}
}
</script>
However, this isn't ideal because you have to declare the interface manually and there can be a discrepancy between the interface and the actual component. Developers modifying the component could potentially forget to update the interface and this would most likely cause bugs.
The solution would be to use Vue 3 have a single source of truth by deriving the interface directly from the component itself. This could be done in the form of a generic utility type that takes in the class of the component and outputs an interface for its properties: PropsOf<SimpleCounter>
.
The library Vue Property Decorators
has this Prop
decorator that automagically assigns class attributes as props. It's also possible to set default values to props using the following syntax: @Prop({ default: 'hey' })
.
This allows us to have strictly defined (as opposed to optional) attributes from an internally in the component and still accept optional properties from the parent component.
We had the idea of using the class inferred interface to type the properties, but we get a bunch of Vue internals as properties such as $refs
and $slots
.
A good way to get rid of these is to omit the keys of Vue
.
This is slightly better, but we still get all of the component functions in the auto-completion, which is bad. What if it were possible to define the props and the functions separately? Let's try it!
// SimpleCounter.vue
<template>
{{ value }}
</template>
<script lang="ts">
@Component
class SimpleCounterProps extends Vue {
@Prop() step!: number;
@Prop({ default: 0 }) start!: number;
}
@Component
export default class SimpleCounter extends SimpleCounterProps {
private value: number = 0;
created() {
this.value = this.start;
}
increment() {
this.value += this.step;
}
}
</script>
We now have a simple way to get an interface for the only the properties, or do we? Well yes, kind of, but also no, not really. The types are not fully accurate yet.
As we can see, step
is correctly typed, but start
is not defined as an optional property. What we're looking for here is to have start?
instead of simply start
. We can do this by splitting the properties declaration into two different classes. The first class would contain the required properties and the second class would contain the optional properties.
// SimpleCounter.vue
<template>
{{ value }}
</template>
<script lang="ts">
@Component
class SimpleCounterRequiredProps extends Vue {
@Prop() step!: number;
}
@Component
class SimpleCounterOptionalProps extends Vue {
@Prop({ default: 0 }) start!: number;
}
@Component
export default class SimpleCounter extends Mixins(RequiredProps, OptionalProps) {
private value: number = 0;
created() {
this.value = this.start;
}
increment() {
this.value += this.step;
}
}
</script>
This makes it easy to derive the correct types using a custom utility type.
// utilities.ts
type PropsOf<RequiredProps extends Vue, OptionalProps extends Vue> = Omit<Required<RequiredProps> & Partial<OptionalProps>, keyof Vue>;
Finally, let's export the derived interface.
// SimpleCounter.vue
// ...
export type SimpleCounterProps = PropsOf<SimpleCounterRequiredProps, SimpleCounterOptionalProps>;
// ...
Now we can import the property types in other components and we get exactly what we were looking for:
🎉🎉🎉
I would have really liked to be able to use the PropsOf<SimpleCounter>
type with a single parameter, but I haven't found a way to achieve this yet.
Top comments (0)