2

I use Vue.js and Vuex for some time, but always with javascript.

I'm trying to use Vue with Typescript, nuxt.js to be more specifically, but without using decorators or style-class-component, only continue with the normal Vue syntax

This is the code I have in my Vuex store

/store/todos/types.ts

export interface Todo {
  id: number
  text: string
  done: boolean
}

export interface TodoState {
  list: Todo[]
}

/store/todos/state.ts

import { TodoState } from './types'

export default (): TodoState => ({
  list: [
    {
      id: 1,
      text: 'first todo',
      done: true
    },
    {
      id: 2,
      text: 'second todo',
      done: false
    }
  ]
})

/store/todos/mutations.ts

import { MutationTree } from 'vuex'
import { TodoState, Todo } from './types'

export default {
  remove(state, { id }: Todo) {
    const index = state.list.findIndex((x) => x.id === id)
    state.list.splice(index, 1)
  }
} as MutationTree<TodoState>

/store/todos/actions.ts

import { ActionTree } from 'vuex'
import { RootState } from '../types'
import { TodoState, Todo } from './types'

export default {
  delete({ commit }, { id }: Todo): void {
    commit('remove', id)
  }
} as ActionTree<TodoState, RootState>

/store/todos/getters.ts

import { GetterTree } from 'vuex'
import { RootState } from '../types'
import { TodoState, Todo } from './types'

export default {
  list(state): Todo[] {
    return state.list
  }
} as GetterTree<TodoState, RootState>

This is code that I have my component,

<template>
  <div>
    <ul>
      <li v-for="todo in todos" :key="todo.id">
        {{ todo.text }}
        <button @click="destroy(todo)">delete</button>
      </li>
    </ul>
  </div>
</template>

<script lang="ts">
import Vue from 'vue'
import { mapGetters, mapActions } from 'vuex'

export default Vue.extend({
  computed: {
    ...mapGetters({
      todos: 'todos/list'
    })
  },
  methods: {
    ...mapActions({
      destroy: 'todos/delete'
    })
  }
})
</script>

Everything works perfectly, except the auto complete / intellisense of the getters or actions that came from Vuex

Someone can help me?

Thanks for this o/

Yung Silva
  • 1,324
  • 4
  • 20
  • 40

2 Answers2

1

Vuex, in current form, doesn't work well with Typescript. That's probably going to change in Vue 3.

Just as you, I also don't want to use @Component decorators, especially because they have been deprecated. However, when it comes to using the default Vue typescript component style:

<script lang="ts">
  import Vue from 'vue';
  export default Vue.extend({...})
</script>

... after testing multiple solutions I found the easiest to use is actually a plugin which does use decorators: vuex-module-decorators

Vuex module:

I typically leave the parent state clean (empty) and use namespaced modules. I do it mostly because more than once I decided at the end of the project it would be cleaner to have more than one module, and it's more of a hassle to move it from parent to module than to simply create an additional module.

The store looks like this:

import Vue from 'vue';
import Vuex from 'vuex';
import { getModule } from 'vuex-module-decorators';
import Whatever from '@/store/whatever';

Vue.use(Vuex);

const store = new Vuex.Store({
  modules: {
    whatever: Whatever
  }
});

getModule(Whatever, store); // this is important for typescript to work properly

export type State = typeof store.state;
export default store;

Here are a few examples of mapState, mapGetters or get/set computed that work directly with the store:

computed: {
  ...mapGetters({
    foo: 'whatever/foo',
    bar: 'whatever/bar'
  }),
  ...mapState({
    prop1: (state: State): prop1Type[] => state.whatever.prop1,
    prop2: (state: State): number | null => state.whatever.prop2
  }),
  // if i want get/set, for a v-model in template
  baz: {
    get: function(): number {
      return this.$store.state.whatever.baz;
    },
    set: function(value: number) {
      if (value !== this.baz) { // read * Note 1
        this.$store.dispatch('whatever/setBaz', value);
        // setBaz can be an `@Action` or a `@MutationAction`
      }
    }
  }
}

baz can now be used in a v-model. Note mapGetters need to be actual module store getters:

import { $http, $store } from '@/main'; // read * Note 2
import { Action, Module, Mutation, MutationAction, VuexModule } from 'vuex-module-decorators';

@Module({ namespaced: true, store: $store, name: 'whatever' })
export default class Whatever extends VuexModule {

  get foo() {
    return // something. `this` refers to class Whatever and it's typed
  }
  baz = 0;
  prop1 = [] as prop1Type[];       // here you cast the type you'll get throughout the app
  prop2 = null as null | number;   // I tend not to mix types, but there are valid cases 
                                   // where `0` is to be treated differently than `null`, so...
  @MutationAction({ mutate: ['baz'] })
  async setBaz(baz: number) {
    return { baz }
  }
}

Now, you won't have any trouble using @Action or @Mutation decorators and you can stop there, you won't have any typescript problems. But, because I like them, I find myself using @MutationActions a lot, even though, to be fair, they're a hybrid. A hack, if you want.
Inside a @MutationAction, this is not the module class. It's an ActionContext (basically what the first param in a normal js vuex action would be):

interface ActionContext<S, R> {
  dispatch: Dispatch;
  commit: Commit;
  state: S;
  getters: any;
  rootState: R;
  rootGetters: any;
}

And that's not even the problem. The problem is Typescript thinks this is the module class inside a @MutationAction. And here's when you need to start casting or use typeguards. As a general rule, I try to keep casting to a minimum and I never use any. Typeguards can go a long way.
The golden rule is: If I need to cast as any or as unknown as SomeType, it's a clear sign I should split the @MutationAction into an @Action and a @Mutation. But in vast majority of cases, a typeguard is enough. Example:

import { get } from 'lodash';
...
@Module({ namespaced: true, store: $store, name: 'whatever' })
export default class Whatever extends VuexModule {
  @MutationAction({ mutate: ['someStateProp'] })
  async someMutationAction() {
    const boo = get(this, 'getters.boo'); // or `get(this, 'state.boo')`, etc...
    if (boo instaceof Boo) {
      // boo is properly typed inside a typeguard
      // depending on what boo is, you could use other typeguards:
      // `is`, `in`, `typeof`  
    }
}

If you only need the values of state or getters: this.state?.prop1 || [] or this.getters?.foo also work.

In all fairness, @MutationAction requires some form of type hacking, since you need to declare the types: they are not inferred properly. So, if you want to be 100% correct, limit their usage to cases where you're simply setting the value of a state property and you want to save having to write both the action and the mutation:

@MutationAction({ mutate: ['items'] })
async setItems(items: Item[]) {
  return { items }
}

Which replaces:

@Action
setItems(items: Item[]) {
  this.context.commit('setItems', items);
  // btw, if you want to call other @Action from here or any @MutationAction
  // they work as `this.someAction();` or `this.someMutationAction()`;
}

@Mutation
setItems(items: Item[]) {
  this.items = items;
}

@MutationActions are registered as @Actions, they take a { mutate: [/* full list of props to be mutated*/]} and return an object having all the declared state props which are declared in the array of props to be mutated.

That's about it.


* Note 1: I had to use that check when I used two different inputs (a normal one and a slider input) on the same get/set v-model. Without that check, each of them would trigger a set when updated, resulting in a stack-overflow error. You normally don't need that check when you only have 1 input.

* Note 2: here's how my main.ts typically looks like

import ...
Vue.use(...);
Vue.config...

const Instance = new Vue({
  ...
}).$mount(App);

// anything I might want to import in components, store modules or tests:
export { $store, $t, $http, $bus } = Instance; 
/* I'd say I use these imports more for correct typing than for anything else 
 (since they're already available on `this` in any component). But they're 
 quite useful outside of components (in services, helpers, store, translation 
 files, tests, etc...)
 */
tao
  • 82,996
  • 16
  • 114
  • 150
  • my final question, did you find any way to make it work, without using `vuex-module-decorators`? I would like to write the Vuex modules, in the usual way, without decorators... if the best solution so far is to use it with decorators, can you tell me that I can separate the files from the module? example `actions.ts`, `getters.ts`, `mutations.ts`, `state.ts`, if yes, do you have any examples of how to do this? – Yung Silva May 29 '20 at 03:04
  • 1
    I have, but they are limited. You run into edge cases. They can all be solved, but you need to write more code (typeguards or casting) - which is what I want to avoid. I want typescript to infer the types, I don't want to have to specify them. In regards to separating `getters`, `mutations`, `actions` and `state`, I haven't found a typescript friendly way of doing it. We did try it for a larger project and after more than a week we decided it's not worth the effort and left it as `.js`. – tao May 29 '20 at 08:12
  • @tao I think this might be possible now in the future with new Typescript features, using only inferral. See my answer below, would be happy to get your feedback expecially if there are any drawbacks since you seem to have been working a lot with Typescript and vuex. – joakimriedel Sep 28 '21 at 13:29
1

While searching for a solution for this issue, I found this question. I did some experimenting, and think there is a solution.

The trick is to wrap the methods mapGetters and mapActions so that it is possible to let Typescript infer the types involved. It will give you compile time errors if you provide the wrong key to the mapper, and as a bonus the return types will be correct (no more any)

// returns a type which skips the first context argument
type OmitActionContext<F> = F extends (
  injectee: ActionContext<any, any>,
  payload: infer P
) => infer R
  ? (payload?: P) => Promise<PromiseValue<R>>
  : never;

// signature of action methods
type ActionMethod = (
  injectee: ActionContext<any, any>,
  ...args: any[]
) => Promisable<any>;

/** Typed wrapper for mapActions using a namespaced store and renaming the keys
 *
 *  NOTE: needs to be called with extra parenthesis to infer map keys correctly
 *
 * @example
 *  mapActionsNamespacedWithRename<TYPE>()(namespace, map)
 *
 */
export const mapActionsNamespacedWithRename = <
  S extends Record<keyof S & string, ActionMethod>,
  Keys extends keyof S & string = keyof S & string
>() => {
  function anonymous<Prop extends string, Mp extends Record<Prop, Keys>>(
    namespace: string,
    map: Mp
  ): {
    [P in Keys as GetKeyByValue<Mp, P>]: OmitActionContext<S[P]>;
  };
  function anonymous<Prop extends string, Mp extends Record<Prop, Keys>>(
    namespace: string,
    map: Mp
  ) {
    return mapActions(namespace, map);
  }
  return anonymous;
};

Using the above wrapper the payload and Promise return type will be inferred correctly.

You would need to type your /store/todos/actions.ts as follows

import { ActionContext } from 'vuex'
import { RootState, RootGetters } from '../types'
import { TodoState, Todo } from './types'

export type TodoActionMethods = {
  delete: (injectee: ActionContext<TodoState, RootState>, payload: Todo) => void
}

export default {
  delete({ commit }, payload): void {
    const {id} = payload;
    commit('remove', id)
  }
} as ActionTreeTyped<
  TodoState,
  RootState,
  TodoActionMethods,
  TodoGetters,
  RootGetters
>

and then in your component you would need to use the wrapper above. Note the extra parenthesis and generic type added.

  methods: {
    ...mapActionsNamespacedWithRename<TodoActionMethods>()("todos", {
      destroy: 'delete'
    })
  }

No module augmentation - pure Typescript magic!

See the following gist for the complete solution

joakimriedel
  • 1,801
  • 12
  • 27