DEV Community

Cover image for Vuex + TypeScript
Andrew
Andrew

Posted on • Edited on

Vuex + TypeScript

Preface

🚨 The approach, described in this article, is not encouraged to be used in production, till Vuex 4.x and Vue.js 3.x are completely released. Vuex 4.x and Vue.js 3.x API are still unstable. The article just illustrates my attempts to statically type Vuex store, since Vuex@v4.0.0-beta.1 has removed its global types.


⚠️ The project configuration section is intentionally omitted. All the source code is located in this repository.

Introduction

Vuex@v4.0.0-beta.1 is officially released. One of the breaking changes that was introduced is that the library is no more shipped with global typings for this.$store within Vue Component.

Vuex 4 removes its global typings for this.$store within Vue Component

More information about reasons and motivations behind it you can find in this issue.

Since global typings is removed, it's up to a developer to define it by himself. As stated in release notes:

When using TypeScript, you must provide your own augment declaration.

In this article I want to share my experience of augmenting types of a store. I will demonstrate this with an example of simple store. For simplicity, our store is as dumb as possible.

Let's do some coding.

State

A definition of a store starts with a definition of state.

state.ts:



export const state = {
  counter: 0,
}

export type State = typeof state


Enter fullscreen mode Exit fullscreen mode

We need to export type of a state because it will be used in definitions of getters, mutations and actions.

So far so good. Let's go ahead to mutations.

Mutations

As stated in the Vuex docs:

It is a commonly seen pattern to use constants for mutation types in various Flux implementations.

So, all of our possible names of mutations will be stored in the MutationTypes enum.

mutation-types.ts:



export enum MutationTypes {
  SET_COUNTER = 'SET_COUNTER',
}



Enter fullscreen mode Exit fullscreen mode

Now that we have defined the names of mutations, we can declare a contract for each mutation (its actual type). Mutation is just a simple function, which accepts state as the first argument and payload as the second, and eventually mutates the former. State type comes in action, it is used as the type of the first argument. The second argument is specific to a particular mutation. We already know that we have SET_COUNTER mutation, so let's declare types for it.

mutations.ts:



import { MutationTypes } from './mutation-types'
import { State } from './state'

export type Mutations<S = State> = {
  [MutationTypes.SET_COUNTER](state: S, payload: number): void
}


Enter fullscreen mode Exit fullscreen mode

Great! It's time to implement it.



import { MutationTree } from 'vuex'
import { MutationTypes } from './mutation-types'
import { State } from './state'

export type Mutations<S = State> = {
  [MutationTypes.SET_COUNTER](state: S, payload: number): void
}

export const mutations: MutationTree<State> & Mutations = {
  [MutationTypes.SET_COUNTER](state, payload: number) {
    state.counter = payload
  },
}


Enter fullscreen mode Exit fullscreen mode

The mutations variable is responsible for storing all of implemented mutations, and eventually will be used to construct the store.

MutationTree<State> & Mutations intersection of types guarantees that a contract is correctly implemented. If it isn't, TypeScript complains and we get the following error:

TypeScript complains about an unimplemented contract



Type '{ SET_COUNTER(state: { counter: number; }, payload: number): void; }' is not assignable to type 'MutationTree<{ counter: number; }> & Mutations<{ counter: number; }>'.
  Property '[MutationTypes.RESET_COUNTER]' is missing in type '{ SET_COUNTER(state: { counter: number; }, payload: number): void; }' but required in type 'Mutations<{ counter: number; }>'


Enter fullscreen mode Exit fullscreen mode

Just a few words about MutationTree type. MutationTree is a generic type, that is shipped with the vuex package. From its name it's clear, that it helps to declare a type of mutation tree.

vuex/types/index.d.ts:



export interface MutationTree<S> {
  [key: string]: Mutation<S>;
}


Enter fullscreen mode Exit fullscreen mode

But it's not specific enough to suit our needs, because it supposes that a name of mutation can be any string, but in our case we know that a name of mutation can be only typeof MutationTypes. We have left this type just for compatibility with Store options.

Actions

There is no need for actions for such a simple store, but to illustrate typing for actions, let's imagine that we can fetch counter from somewhere.

In the same way as we store names of mutations we store names of actions.

action-types.ts:



export enum ActionTypes {
  GET_COUTNER = 'GET_COUTNER',
}


Enter fullscreen mode Exit fullscreen mode

actions.ts:



import { ActionTypes } from './action-types'

export const actions = {
  [ActionTypes.GET_COUTNER]({ commit }) {
    return new Promise((resolve) => {
      setTimeout(() => {
        const data = 256
        commit(MutationTypes.SET_COUNTER, data)
        resolve(data)
      }, 500)
    })
  },
}


Enter fullscreen mode Exit fullscreen mode

We have a simple GET_COUNTER action which returns Promise, which is resolved in 500ms. It commits the previously defined mutation (SET_COUNTER). Everything seems okay, but commit allows committing any mutation, which is inappropriate, because we know the we can commit just defined mutations. Let's fix it.



import { ActionTree, ActionContext } from 'vuex'
import { State } from './state'
import { Mutations } from './mutations'
import { ActionTypes } from './action-types'
import { MutationTypes } from './mutation-types'

type AugmentedActionContext = {
  commit<K extends keyof Mutations>(
    key: K,
    payload: Parameters<Mutations[K]>[1]
  ): ReturnType<Mutations[K]>
} & Omit<ActionContext<State, State>, 'commit'>

export interface Actions {
  [ActionTypes.GET_COUTNER](
    { commit }: AugmentedActionContext,
    payload: number
  ): Promise<number>
}

export const actions: ActionTree<State, State> & Actions = {
  [ActionTypes.GET_COUTNER]({ commit }) {
    return new Promise((resolve) => {
      setTimeout(() => {
        const data = 256
        commit(MutationTypes.SET_COUNTER, data)
        resolve(data)
      }, 500)
    })
  },
}


Enter fullscreen mode Exit fullscreen mode

In the same way as we declare a contract for mutations we declare a contract for actions (Actions). We must also augment the ActionContext type which is shipped with the vuex package, because it supposes we can commit any mutation. AugmentedActionContext do the job, is restricts committing only the declared mutations (it also checks payload type).

Typed commit inside actions:

Typed commit

Improperly implemented action:

Improperly implemented action

Getters

Getters are also amenable to be statically typed. A getter is just like mutation, and is essentially a function which receives state as its first argument. A declaration of getters is not much different from a declaration of mutations.

getters.ts:



import { GetterTree } from 'vuex'
import { State } from './state'

export type Getters = {
  doubledCounter(state: State): number
}

export const getters: GetterTree<State, State> & Getters = {
  doubledCounter: (state) => {
    return state.counter * 2
  },
}


Enter fullscreen mode Exit fullscreen mode

Global $store type

Core modules of the store have been defined, and now we can actually construct the store. A processes of store creation in Vuex@v4.0.0-beta.1 is slightly different from Vuex@3.x. More information about it is located in release notes. The Store type should be declared to safely access the defined store in components. Note that default Vuex types: getters, commit and dispatch should be replaced with types which we have defined earlier. The reason of this replacement is that default Vuex store types is too general. Just look at the default getters types:



export declare class Store<S> {
  // ...
  readonly getters: any;
  // ...
}


Enter fullscreen mode Exit fullscreen mode

Without a doubt, these types are not suitable in case you want to safely work with a typed store.

store.ts:



import {
  createStore,
  Store as VuexStore,
  CommitOptions,
  DispatchOptions,
} from 'vuex'
import { State, state } from './state'
import { Getters, getters } from './getters'
import { Mutations, mutations } from './mutations'
import { Actions, actions } from './actions'

export const store = createStore({
  state,
  getters,
  mutations,
  actions,
})

export type Store = Omit<
  VuexStore<State>,
  'getters' | 'commit' | 'dispatch'
> & {
  commit<K extends keyof Mutations, P extends Parameters<Mutations[K]>[1]>(
    key: K,
    payload: P,
    options?: CommitOptions
  ): ReturnType<Mutations[K]>
} & {
  dispatch<K extends keyof Actions>(
    key: K,
    payload: Parameters<Actions[K]>[1],
    options?: DispatchOptions
  ): ReturnType<Actions[K]>
} & {
  getters: {
    [K in keyof Getters]: ReturnType<Getters[K]>
  }
}


Enter fullscreen mode Exit fullscreen mode

I will not focus on the TypeScript's Utility Types.

We are at the finish line. All is left is the augmentation of the global Vue types.

types/index.d.ts:



import { Store } from '../store'

declare module '@vue/runtime-core' {
  interface ComponentCustomProperties {
    $store: Store
  }
}


Enter fullscreen mode Exit fullscreen mode

Perfect! We are ready to enjoy a fully typed store access.

Usage in components

Now that our store is correctly declared and is statically typed, we can utilize it in our components. We will take a look at a store usage in components defined with Options API and Composition API syntax, since Vue.js 3.0 supports both.

Options API



<template>
  <section>
    <h2>Options API Component</h2>
    <p>Counter: {{ counter }}, doubled counter: {{ counter }}</p>
    <input v-model.number="counter" type="text" />
    <button type="button" @click="resetCounter">Reset counter</button>
  </section>
</template>

<script lang="ts">
import { defineComponent } from 'vue'
import { MutationTypes } from '../store/mutation-types'
import { ActionTypes } from '../store/action-types'

export default defineComponent({
  name: 'OptionsAPIComponent',
  computed: {
    counter: {
      get() {
        return this.$store.state.counter
      },
      set(value: number) {
        this.$store.commit(MutationTypes.SET_COUNTER, value)
      },
    },
    doubledCounter() {
      return this.$store.getters.doubledCounter
    }
  },
  methods: {
    resetCounter() {
      this.$store.commit(MutationTypes.SET_COUNTER, 0)
    },
    async getCounter() {
      const result = await this.$store.dispatch(ActionTypes.GET_COUTNER, 256)
    },
  },
})
</script>


Enter fullscreen mode Exit fullscreen mode

Typed state:
Typed state

Typed getters:
Typed getters

Typed commit:
Typed commit

Typed dispatch:
Typed dispatch

Composition API

To use store in a component defined using Composition API, we must access it via useStore hook, which just returns our store:



export function useStore() {
  return store as Store
}


Enter fullscreen mode Exit fullscreen mode


<script lang="ts">
import { defineComponent, computed, h } from 'vue'
import { useStore } from '../store'
import { MutationTypes } from '../store/mutation-types'
import { ActionTypes } from '../store/action-types'

export default defineComponent({
  name: 'CompositionAPIComponent',
  setup(props, context) {
    const store = useStore()

    const counter = computed(() => store.state.counter)
    const doubledCounter = computed(() => store.getters.doubledCounter)

    function resetCounter() {
      store.commit(MutationTypes.SET_COUNTER, 0)
    }

    async function getCounter() {
      const result = await store.dispatch(ActionTypes.GET_COUTNER, 256)
    }

    return () =>
      h('section', undefined, [
        h('h2', undefined, 'Composition API Component'),
        h('p', undefined, counter.value.toString()),
        h('button', { type: 'button', onClick: resetCounter }, 'Reset coutner'),
      ])
  },
})
</script>


Enter fullscreen mode Exit fullscreen mode

Typed state:
Typed state

Typed getters:
Typed getters

Typed commit:
Typed commit

Typed dispatch:
Typed dispatch

Conclusion

The result of our efforts is fully statically typed store. We are allowed to commit/dispatch only declared mutations/actions with appropriate payloads, otherwise we get an error.

By now Vuex does not provide correct helpers to facilitate process of typing, so we have to do it manually. Hope, that the following versions of Vuex will be shipped with the flexible store typing.

Latest comments (40)

Collapse
 
khalilsayhi profile image
khalil_sayhi

Hey guys, amazing post, i have only one question, how can i access a specific module state in another, Thank you in advance.

Collapse
 
wobsoriano profile image
Robert

Better than most of typescript vuex classes and accessors out there tbh

Collapse
 
jay_2ae6ad576a843f profile image
Yunjie Jia

actually, I have a question, according to this line

payload: Parameters[1]

it forces me to commit a mutation with payload, but what if I just want to commit a mutation without a payload?

like the RESET_COUNTER action in your article

Collapse
 
izerozlu profile image
izerozlu

You can mark the payload parameter as optional.

Collapse
 
he110te4m profile image
牡龙

I have same problem. Did you have the solution?

Collapse
 
harshgupta3 profile image
Harsh Gupta • Edited

Hey Andrew, thanks for this great example.
can you please help with namespaced modules in vuex4 with typescript

I'm using your suggested way but I'm still getting the error

type Namespaced = {
[P in keyof T & string as ${N}/${P}]: T[P]
}
type NamespacedMutations = Namespaced

error

error

Collapse
 
jay_2ae6ad576a843f profile image
Yunjie Jia

Hi, Harsh. I tried your method, but I still cannot use

store.dispatch('moduleName/actionName'), is there any fixes..

Collapse
 
harshgupta3 profile image
Harsh Gupta • Edited

Yes I solved, This is the solution you can refer

Solution for namespaced mutations

If you want a complete boilerplate with vue3 and other more configs you can clone or fork my repository.

github.com/techarshgupta/vue3-boil...

Thread Thread
 
jay_2ae6ad576a843f profile image
Yunjie Jia

Thank you!

I tried this method but seems like it will disable type check when I use

store.dispatch('moduleName/actionName') because I can pass any string and the compiler can compile successfully

type Namespaced = {

};
type NamespacedActions = Namespaced;

Thread Thread
 
harshgupta3 profile image
Harsh Gupta

no, it will give an error when you compile you can check my repo that I mentioned.

Collapse
 
yocss profile image
Yocss

Hey Andrew, great article, can i translate into chinese to release to china forum?
I will mark your nickname and origin link in the article.

Collapse
 
mat813 profile image
Mathieu Arnold • Edited

So, I was applying all the wisdom in here, to my quite large vuex stack, and I stumbled upon the second argument of Getters (I leave the third and fourth to the reader)

Basically, the second argument to Getters references the Getters for the current namespace, but not as functions, as values directly.

For example, I have this:

export type Getters = {
  user(state: State): PayloadUser | {};
  roles(state: State, getters: any): string[];
  isAdmin(state: State, getters: any): boolean;
};

export const getters: GetterTree<State, State> & Getters = {
  user({ token }) {
    if ('user' in token) {
      return token.user;
    } else {
      return {};
    }
  },
  roles(_, getters) {
    if ('roles' in getters.user) {
      return getters.user.roles;
    } else {
      return [];
    }
  },
  isAdmin(_, getters) {
    return getters.roles.includes('admin');
  },
Enter fullscreen mode Exit fullscreen mode

The problem is that the getters argument as an "any" type everywhere, I tried to put the Getters type, but that was not right. After a bit of fiddeling around (I am still a bit new and rough in typescript) I ended up having this :

export type SubGetters<T extends { [K in keyof T]: (...any: any[]) => any }> = {
  [K in keyof T]: ReturnType<T[K]>;
};

export type Getters = {
  user(state: State, getters: SubGetters<Getters>): PayloadUser | {};
  roles(state: State, getters: SubGetters<Getters>): string[];
  isAdmin(state: State, getters: SubGetters<Getters>): boolean;
};
Enter fullscreen mode Exit fullscreen mode

The naming is probably awful, and now that I think about it, one should just have to generate a Getters that is, say, user: PayloadUser; roles: string[]; and a different kind of fiddeling be done so that the type with the function definitions can be generated automatically.

Also, if all this goes into a module somewhere, this should probably get there too.

Collapse
 
mat813 profile image
Mathieu Arnold

So, I thought about what I wrote in the end and changed my code, it now reads:

export type FunGetters<T extends { [K in keyof T]: any }> = {
  [K in keyof T]: (state: State, getters: Getters) => T[K];
};

export type Getters = {
  loggedIn: boolean;
  expirationTime: number;
  user: PayloadUser | {};
  roles: string[];
  isAdmin: boolean;
};

export const getters: GetterTree<State, State> & FunGetters<Getters> = {
// [...]
Enter fullscreen mode Exit fullscreen mode
Collapse
 
tbhaxor profile image
Gurkirat Singh • Edited

I see you are using type intersection for example GetterTree<State, State> & Getters. I have a general dbt, why did you choose this way but not interfaces.

In interfaced you in extend the GetterTree<State, State> and then use it in the Getters part.

interface Getter extends GetterTree<State, State> {
 // data
}
Enter fullscreen mode Exit fullscreen mode
Collapse
 
deeja profile image
Daniel Blackwell

Great article!
Just a note that you have a spelling mistake GET_COUTNER rather than GET_COUNTER

Collapse
 
lostimever profile image
lostimever

Thank you! It's vary nice article! However, how to apply it in mapState, mapGetters, mapMutations, mapActions?

Collapse
 
chrisross5 profile image
Chris

Great question

Collapse
 
belvederef profile image
Francesco Belvedere

I am still disappointed with how difficult Vuex+Typescript is to set up. I will keep using module-decorators (github.com/championswimmer/vuex-mo...) which let you have complete type safety and no redundancy in your code:

@Module
export default class Counter extends VuexModule {
  count = 0

  @Mutation
  INCREMENT(delta: number) {
    this.count += delta
  }

  @Action
  incrementOfFive() {
    this.INCREMENT(5);
  }
}
Enter fullscreen mode Exit fullscreen mode

And then just use it in a component with:

import store from '@/store'
...
store.incrementOfFive();
// or
store.INCREMENT(7);
Enter fullscreen mode Exit fullscreen mode

Some comments may only be visible to logged-in visitors. Sign in to view all comments.