<< back to the blog

Vuex with TypeScript: Tricks to Improve your Developer Experience

© Doug Keown on Unsplash

Vue.js is a popular JavaScript framework which recently surpassed React in stars on GitHub. And TypeScript is getting more popular as well. So, combining the two is a likely choice for a new web app project. Our team made the same decision but soon found out that the Vue ecosystem and TypeScript are not a perfect match.

Vue itself has a decent TypeScript support nowadays and will even be re-written in TypeScript for its next major version. However, its official state management library Vuex still lacks thorough type definitions.

The type definitions for defining getters, mutations and actions are quite decent and the related code usually resides within one store or module file, so weak or missing type definitions are a minor issue here. It is the caller side – when you access the store from one of your Vue components – which we will focus on improving. The calling code is spread througout the whole code base, so this is where we benefit most from better type support.

The whole source code used in the following examples is available on GitHub.

There is one more thing we should clarify before we go into the details. We use Vue Class Component for defining our components because it is a really good fit with TypeScript classes. If you are using the standard Vue components definitions, the type definitions you need to change are probably quite different to what we are going to cover here.

State

Accessing the global state from any Vue instance is quite easy: this.$store.state. But when we take a look at the type of this.$store.state, the IDE gives us a disappointing any type. The TypeScript compiler does not know which type our state is, even though we nicely defined our state interface when creating the store. A look at how this.$store is defined, gives us the reason for this.

declare module "vue/types/vue" {
  interface Vue {
    $store: Store<any>;
  }
}

 
What we need is $store to be of type Store<State> to get the full TypeScript support for this.$store.state. Unfortunately, we cannot override the type of this.$store. The Vuex type definitions use TypeScript's Interface Merging feature to extend the default Vue interface and it does not allow changing the type of an existing property. But we can redefine it with a different name:

declare module "vue/types/vue" {
  interface Vue {
    $vStore: Store<State>;
  }
}

const typedStorePlugin: PluginObject<void> = {
  install(VueInstance: typeof Vue) {
    Object.defineProperty(VueInstance.prototype, '$vStore', {
        get() {
          return this.$store;
        }
    });
  }
};

Vue.use(Vuex);
Vue.use(typedStorePlugin);


The first part of the code defines a new property called $vStore which can be accessed in any Vue instance. The second part defines a Vue plugin which registers a getter $vStore which simply returns $store. The last lines register both Vuex and our own plugin with Vue.

Now, we have beautiful code completion and proper refactoring support in our Vue components:

Getters

Getters are a bit trickier. They are already defined as any in Vuex’s type definition for Store. So we need to redefine it. We could use interface merging again to redefine it with a different name but there is an easier way. TypeScript allows us to override the property’s type to a more strict type in subclasses. And with a bit more TypeScript wizardry, we can couple our own Getter interface with the getter implementation definitions.

interface Getters {
  todosCount: number;
}

type GettersDefinition = {
  [P in keyof Getters]: (state: State, getters: Getters) => Getters[P];
}

interface MyStore extends Store<State> {
  getters: Getters;
}

declare module "vue/types/vue" {
  interface Vue {
    $vStore: MyStore;
  }
}

const getters: GettersDefinition = {
  todosCount: state => state.todos.length
};

export default new Store<State>({
  state: {
    todos: []
  },
  getters: getters,
  ...
});


The GetterDefinition interface ensures that we have all properties from the Getter interface defined in our store ([P in keyof Getters]) and that those defintions return the correct types (=> Getters[P]).

Mutations and Actions

Mutations and actions follow the same principle, so we will tackle them in this part collectively. Mutations and actions are not defined as named properties like states and getters. Instead, you call commit/dispatch and specify which mutations/action to use based on the passed string. When calling commit/dispatch TypeScript is unable to check if a mutation or action with the passed name exists. So instead we want a list of available mutations/actions and the easiest way to do that is specifying them using enums. We can then override the commit and dispatch definitions in our MyStore interface to only allow those enum values as types.

export enum Actions {
  AddTodo="ADD_TODO"
}

type ActionsDefinition = {
  [P in Actions]: Action<State, State>;
}

interface BaseActionPayloadWithType {
  type: Actions;
}

export interface MyDispatch {
  (type: Actions, payload?: any, options?: DispatchOptions): Promise<any>;
  <P extends BaseActionPayloadWithType>(payloadWithType: P, options?: DispatchOptions): Promise<any>;
}

interface MyStore extends Store<State> {
  getters: Getters;
  dispatch: MyDispatch;
}

const actions: ActionsDefinition = {
  [Actions.AddTodo]: (injectee: ActionContext<State, State>, payload: string) => injectee.commit("ADD_TODO", payload)
};

 
Similar to our GetterDefinition from before, we use our custom ActionsDefinition to ensure our store implementation matches the enum values.

The example above only shows the changes needed for actions but changing mutations would work in the same way.

Going further

Now we have type safety for which mutations and actions are allowed. However, we are not fully done yet. The payload for mutations and actions is still of type any. We can fix this by adding a small facade which ensures the passed payload has the right type.

class ActionFacade {
  constructor(private readonly store: MyStore) {}
  addTodo(todo: string) {
    return this.store.dispatch('addTodo', todo);
  }

}

type ActionsDefinition = {
  [P in keyof ActionFacade]: (this: Store<State>, injectee: ActionContext<State, State>, payload: Parameters<ActionFacade[P]>[0]) => Promise<void> | void;
}

interface MyStore extends Store<State> {
  getters: Getters;
  commit: MyCommit;
  actions: ActionFacade;
}

const actions: ActionsDefinition = { addTodo: (injectee: ActionContext<State, State>, payload: string) => injectee.commit(Mutations.AddTodo, payload) }; const store = new Store<State>({...}) as MyStore; store.actions = new ActionFacade(store);

 
We also changed our ActionsDefinition to narrow the type of the payload correctly.

Unfortunately, this results in a significant change in how we are used to work with Vuex. If you are not comfortable going that far, feel free to omit this step and stay with the enum version from before.

Modules

We won’t cover support for modules in detail. However, we will briefly discuss how you could tackle it. 

Namespacing Vuex modules is tricky. You need to add the namespace to every getter/mutation/action call outside the module which could prove difficult to typecheck properly. Instead you could try to avoid Vuex’s namespacing mechanism and enforce on your own that you don’t have any name conflicts between getters/mutations/actions of different modules. One possibility would be to define all possible getters/mutations/actions in one file/enum. Alternatively, you could create your own namespacing mechanism by prefixing the value of the TypeScript enum with the namespace, e.g.

enum TodoActions {
  AddTodo = 'TODO__ADD_TODO'
}

Conclusion

Vuex was not built with TypeScript in mind and adding bullet-proof type checks is not an easy endeavor. In the end, adding those safety nets is a trade-off between having a more safe and comfortable developer experience and investing time and effort into something which solves a potentially negligible problem.

<< back to the blog