TypeScript
Vuex
gene
0

Nuxt.js + TypescriptでVuex内をタイプセーフにする

概要

会社のプロジェクトでNuxt.jsとVuexとTypescriptを使用していて、Vuexのcommitやdispatchの引数を型推測してくれないところでなんとかならないかなと思ったのが発端です。

課題感

具体的には以下のようなコードの時にcommitするmutationsに応じて引数の型チェックしてくれることが期待する挙動です。

main.ts
type State = {
  hoge: string;
  fuga: number;
};

type ChangeHogePayload = {
  value: string
};

type ChangeFugaPayload = {
  value: number
};


export const state: () => State = (): State => ({
  hoge: '',
  fuga: 0,
});

type Mutations = {
  changeHoge: (state: State, payload: ChangeHogePayload) => void,
  changeFuga: (state: State, payload: ChangeFugaPayload) => void,
  incrementFuga: (state: State) => void,
};
export const mutations: Mutations = {
  changeHoge(state, payload) {
    state.hoge = payload.value;
  },
  changeFuga(state, payload) {
    state.fuga = payload.value;
  },
  incrementFuga(state) {
    state.fuga++;
  }
};

export const actions = {
  mdSave({ commit }, payload) {
    commit('changeHoge', { value: payload.hoge }); // ここで引数の型が違うときに教えてほしい
    commit('changeFuga', { value: payload.fuga }); // ここで引数の型が違うときに教えてほしい
  },
  mdSaveRoot({ commit }, payload) {
    commit('changeRootHoge', { value: payload.hoge }, { root: true }); // rootのmutation commit時にも型が違うときは教えてほしい
    commit('changeRootFuga', { value: payload.fuga }, { root: true }); // rootのmutation commit時にも型が違うときは教えてほしい
  }
};


このようにmutations内では引数payloadの型を推測してくれるものの、actionsからmutationsをcommitするときに上記の書き方だと引数をチェックしてくれないのでそのあたりを簡単に上手くできる方法はないかと模索していました。

ファイル構造

ファイル構造は以下を想定しています。

store/
 ├ index.ts
 ├ index.d.ts
 └ modules/
   └ main.ts

型定義後

.tsファイルと型定義ファイル

以下のように書くことができました。(以下は一部でプロジェクトはこちら:https://github.com/yuki8888nm/nuxt_vuex_typescript

store/modules/main.ts
import { State, ActionPayloads, MutationPayloads, GetterResults, Mutations, Actions, Getters } from '../index.d'

export const state: () => State = (): State => ({
  hoge: '',
  fuga: 0,
});

export const mutations: Mutations<State, MutationPayloads> = {
  changeHoge(state, payload) {
    state.hoge = payload.value;
  },
  changeFuga(state, payload) {
    state.fuga = payload.value;
  },
  incrementFuga(state) {
    state.fuga++;
  }
};

export const actions: Actions<State, MutationPayloads, ActionPayloads, GetterResults> = {
  mdSave({ commit }, payload) {
    commit('changeHoge', { value: payload.hoge });
    commit('changeFuga', { value: payload.fuga });
  },
  mdSaveRoot({ commit }, payload) {
    commit('changeRootHoge', { value: payload.hoge }, { root: true });
    commit('changeRootFuga', { value: payload.fuga }, { root: true });
  }
};

store/index.d.ts
/* common */
export type NonPayload = undefined;

type Commit<MP, RMP> = {
  <K extends keyof MP>(type: K, payload?: MP[K]): Promise<any>;
  <K2 extends keyof RMP>(
    type: K2,
    payload: RMP[K2],
    options: { root: true }
  ): Promise<any>;
};

type Dispatch<AP, RAP> = {
  <K extends keyof AP>(type: K, payload?: AP[K]): Promise<any>;
  <K2 extends keyof RAP>(
    type: K2,
    payload: RAP[K2],
    options: { root: true }
  ): Promise<any>;
};

type ActionContext<S, MP, AP, GR> = {
  state: S;
  commit: Commit<MP, RootMutationPayloads>;
  dispatch: Dispatch<AP, RootActionPayloads>;
  getters: GR;
  rootGetters: RootGetterResults;
  rootState: RootState;
};

export type Mutations<S, MP> = {
  [K in keyof MP]: (state: S, payload: MP[K]) => void
};
export type Actions<S, MP, AP, GR> = {

    context: ActionContext<S, MP, AP, GR>,
    payload: AP[K]
  ) => void
};
export type Getters<S, GR> = { [K in keyof GR]: (state: S) => GR[K] };


/* index */
type ChangeRootHogePayload = {
    value: string
}
type ChangeRootFugaPayload = {
    value: number
}

export type RootState = {
    hoge: string;
    fuga: number;
};

export type RootMutationPayloads = {
    changeRootHoge: ChangeRootHogePayload;
    changeRootFuga: ChangeRootFugaPayload;
};

type RootSavePayload = {
    hoge: string
    fuga: number
}
export type RootActionPayloads = {
      save: RootSavePayload
};

export type RootGetterResults = {
    doubleFuga: number
};


/* main */
type ChangeHogePayload = {
    value: string
}
type ChangeFugaPayload = {
    value: number
}

export type State = {
    hoge: string;
    fuga: number;
};

export type MutationPayloads = {
    changeHoge: ChangeHogePayload;
    changeFuga: ChangeFugaPayload;
    incrementFuga: NonPayload;
};

type SavePayload = {
    hoge: string
    fuga: number
}

export type ActionPayloads = {
    mdSave: SavePayload
    mdSaveRoot: SavePayload
};

export type GetterResults = {
    doubleFuga: number
};

Commitの型定義について

type MutationsはGenericsを使ってbeforeのコードのMutationsのtypeを書き直しただけのものになります。commit時にMutationとその引数の型をチェックできるようになるために、Actionsの引数に当たる部分の型定義が肝になります。

// RMP => RootMutationPayloads
type Commit<MP, RMP> = {
  <K extends keyof MP>(type: K, payload?: MP[K]): Promise<any>;
  <K2 extends keyof RMP>(
    type: K2,
    payload: RMP[K2],
    options: { root: true }
  ): Promise<any>;
};

// 中略

type ActionContext<S, MP, AP, GR> = {
  state: S;
  commit: Commit<MP, RootMutationPayloads>; // MP => MutationPayloads
  dispatch: Dispatch<AP, RootActionPayloads>;
  getters: GR;
  rootGetters: RootGetterResults;
  rootState: RootState;
};

// 中略

/*
* S  => State
* MP => MutationPayloads
* AP => ActionPayloads
* GR => GetterResults
*/
export type Actions<S, MP, AP, GR> = {
    context: ActionContext<S, MP, AP, GR>,
    payload: AP[K]
  ) => void
};

VuexのCommitは第三引数に{root: true}を渡すことでroot StoreのMutationsにcommitすることができます。そのため第三引数でrootかどうかを判断するのでCommitにはこのmoduleのMutationsの型チェックをする
<K extends keyof MP>(type: K, payload?: MP[K]): Promise<any>;
と、rootのMutationsの型チェックをする
<K2 extends keyof RMP>(
type: K2,
payload: RMP[K2],
options: { root: true }
): Promise<any>;

の2つの型定義をもたせています。

CommitのGenericsについて

TypeScriptのGenericsを触れたことのない人もいるかと思うので説明すると、ActionContext<S, MP, AP, GR>と書くことで<>の内側4つは型引数(ActionContextが実際に使用される際に初めて方が決定する)となります。
ここではMPがActions→ ActionContext→ Commitと渡されるようになっているので、main.tsでactionsに定義しているActions<State, MutationPayloads, ActionPayloads, GetterResults>のMutationPayloads(index.d.tsからimportしている)がこれにあたり、commit時に第一引数のtypeからmutationsの引数の型をチェックしてくれます。

また<K extends keyof MP>(type: K, payload?: MP[K]): Promise<any>;ですが、<K extends keyof MP>とすることでMP型のプロパティ名をKと名前付けすることができ、MPのプロパティ名が第一引数typeとして渡された場合はpayloadはMP型のそのプロパティの型となる、という記述です。

commitに関する部分のみ書きましたが、コードはActions、Gettersにも対応しています。

※ commitの第一引数にtypeプロパティを持つobjectを渡すやり方には対応していません

このプロジェクトのリポジトリ

https://github.com/yuki8888nm/nuxt_vuex_typescript

参考

https://github.com/ktsn/vuex-type-helper