loading...
Cover image for Vue.js Typed events

Vue.js Typed events

3vilarthas profile image Andrew ・4 min read

This article is aimed for those who wants to make communication between their components more solid and strictly typed.

Notice. Examples of code, described later in this article, is built using @vue/composition-api, so it's highly encouraged to skim through the RFC of the upcoming Composition API before reading.

Preface

Recently, I have been working on a large-scale project which has approximately 200+ components. The project contains a lot of dumb components, which has no state and accept only props, but also a bunch of stateful components, which tie up these dumb components and contain business logic.

Project's code tends to grow. Business wants new features, and wants them being implemented as fast as possible. When you are set off to release a new feature, there is a big chance that you are going to change the logic of already defined component. Another components might still utilize this component (pass props to it, or listen to its events). So how can you be confident that your changes do not break another components and ultimately the entire system?

Further, I will describe my attempts to make communication between components more reliable and typed.

Typed contracts

The idea is to create typed contracts between components. Every component has it's own "API": props it receives and events it emits. As you might know, events can contain payload (additional data that is attached to event). Thus every component should provide its own API: "I claim that I receive such props with such types and I emit such events with such payload".

JavaScript is dynamically typed, so TypeScript in rescue.

Let's create our first contract. We will use TypeScript interfaces to express that contract.

types.d.ts:

export interface Props {
  messages: {
    id: string
    text: string
    sender: {
      username: string
      avatar?: string
    }
  }[]
}

export interface Events {
  message: MessageEvent
}

export interface MessageEvent {
  text: string
}

Our component declares that it receives array of messages through messages prop, and each message should contain id, text, sender (which should contain username, and also may contain avatar). Also it states that event message with payload of type { text: string } can be emitted.

Remember yourself skimming through component's code trying figure out what events with what payloads it emits? The inner logic of component probably do not bother you at that moment, but you have to skim through it. Dedicated file which contains types(contracts) of our component solves that problem.

Implementation

To properly implement our contracts we must write our components using TypeScript. Unfortunately Vue.js 2.0 is built without proper TypeScript support, while upcoming 3.0 is fully built upon TypeScript. So we are going to use 2.0 @vue/composition-api plugin which adds some new features from 3.0 and also provides us with better TypeScript support.

Let's define our dummy component.

import { createComponent } from '@vue/composition-api'

import { Props } from './types'

export default createComponent({
  name: 'AppChat',
  props: {
    messages: {
      type: Array,
      required: true
    }
  },
  setup(props: Props) {}
})

Vue Composition API provides us with convenient method to define prop types in setup method (props: Props). That's all, we have fully typed props 🎉.

Typed props

The next part is to emit typed events. As stated here the second argument of setup is context. But typings does not suit our purpose:

interface SetupContext {
  // ...
  emit: (event: string, ...args: unknown[]) => void
  // ...
}

So we need to create some sort of wrapper, which knows typings of our events, and will shout out at us if we emit something wrong (something we do not state in our contract).
Let's use feats @vue/composition-api provides to us, namely create custom hook to use it across the components. There is a handy self-speaking method exposed by @vue/composition-apigetCurrentInstance. Below is the code snippet of our code:

emitter.ts:

import { getCurrentInstance } from '@vue/composition-api'

export function useEmitter<T extends Record<string, any>>() {
  const instance = getCurrentInstance()

  return function emit<K extends keyof T>(name: K, payload: T[K]) {
    if (instance !== null) {
      instance.$emit(name, payload)
    }
  }
}

Now it's time to test our hook to see the real benefit.

// ...

import { useEmitter } from '../../composable/emitter'

import { Props, Events } from './types'

export default createComponent({
  // ...
  setup(props: Props) {
    const emitter = useEmitter<Events>()

    function messageHandler(text: string) {
      emitter('message', { text })
    }
  }
})

emitter knows about our events and suggests us message as possible event name.

Emitter suggests event name

Moreover, it warns us, if we pass incorrect payload (incorrect in that case means payload, that does not match with our contract).

Emitter warns about wrong payload

Another benefit is that it's possible to directly import type of certain event and to use it in parent's handler:

import { createComponent, createElement as h } from '@vue/composition-api'

import AppChat from '../AppChat'
import { MessageEvent } from '../AppChat/types'

export default createComponent({
  name: 'AppParent',
  components: {
    AppChat
  },
  setup() {
    function messageHandler(message: MessageEvent) {
      console.log(message.text)
    }

    return () => h(AppChat, { on: { message: messageHandler } })
  }
})

Typed handler

Conclusion

Every approach has its advantages and disadvantages. I will try to outline pros and cons of that approach. Let's begin with pros 😊:

  • strictly typed emits ✅
  • strictly typed event handlers ✅
  • types(contracts) is located in dedicated files ✅

It's turn for cons:

  • you have to use wrapper useEmitter over emit (not emit itself), that adds some overhead to your component's code, and component is highly coupled with that hook, so cannot be easily reused in project which does not contain that hook ❌

Vue.js 3.0 is written from the scratch upon TypeScript, so we can hope that typed emit's will be shipped from out of the box. But for now the only way is to construct our own wrappers to make Vue.js more strictly typed.

Stay tuned, because in the next article I will outline how I managed to patch types of createComponent to substitute type of emit method.

You can play around with the demo here.

Posted on by:

Discussion

markdown guide