DEV Community

Maxime Goyette for Flare

Posted on

Working around Vue 2's lack of types using Vue Property Decorator

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode
// 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>
Enter fullscreen mode Exit fullscreen mode

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.

Image description

A good way to get rid of these is to omit the keys of Vue.

Image description

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>
Enter fullscreen mode Exit fullscreen mode

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.

Image description

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>
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

Finally, let's export the derived interface.

// SimpleCounter.vue
// ...
export type SimpleCounterProps = PropsOf<SimpleCounterRequiredProps, SimpleCounterOptionalProps>;
// ...
Enter fullscreen mode Exit fullscreen mode

Now we can import the property types in other components and we get exactly what we were looking for:

Image description

🎉🎉🎉

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)