26
20

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

vuex を typescript でタイプセーフに実装する

Last updated at Posted at 2018-07-10

目的

  • vuexをtypescriptでタイプセーフに実装したい
  • ktsnさんの vuex-type-helper を使って実装しているのですがコンポーネントからステートやゲッターを参照しようとすると型付けができないので改良したい

前提

  • vue: v2.5.16
  • vuex: v3.0.1
  • vuex-type-helper: v1.2.0
  • ストアはモジュール単位で管理できるように namespaced: true で実装することを前提としています

実装

1. ステートへのアクセスをタイプセーフにする

this.$store.state では型がないので型付きで取得できるようヘルパーを実装します

アイディアとしては モジュール名:ステートの型 のマッピングを定義しそれを利用して型付きでステートを取得するものです

ストアを生成するコード
export class Todo {
  public text: string;
  public done: boolean;
}

export interface TodoState {
  todos: Todo[];
}

const state: TodoState = {
  todos: [],
};

// モジュール名とステートのマッピングの定義
export const todoModuleName = 'todoModule';
export interface StateMaps {
  [todoModuleName]: TodoState;
}

const todoStore = {
  namespaced: true,
  state,
  ....他は割愛
};

export store = new Vuex.Store({
  modules: {
    [todoModuleName]: todoStore,
  },
});

ステートを取得するコンポーネント
import { StateMaps, todoModuleName } from ...

@Component
export default class App extends Vue {
  // コンポーネント上でstateを取得するヘルパー  ※ <1>
  public getState<K extends keyof StateMaps>(module: K): StateMaps[K] {
    return this.$store.state[module];
  }

  public main() {
    const state = this.getState(todoModuleName); // => TodoState型の変数が取得できる
    state.todos[0].text // => string

    const invalidState = this.getState('no_such_module') // => このコードはコンパイルエラー
  }
}
  • ※ <1> `getState(module: K)によってStateMaps` のキーに存在しないモジュール名を引数に渡された場合はコンパイルエラーとなり安全です

2. アクションやゲッターのアクセスもタイプセーフにする

ステートと同じ要領で モジュール名:アクションの型, モジュール名:ゲッターの型 のマッピングを定義しそれを利用して型付きで呼び出せるようにします

(アクションは vuex-type-helper を使えば型付けされるのですが統一感をもたせるために実装しました)

ストアを生成するコード
export interface TodoActions {
  addTodo: {
    text: string,
  };
}

// TodoActionsの具象. DefineActions, DefineGettersの詳細は vuex-type-helper を参照
export const actions: DefineActions<TodoActions, TodoState, TodoMutations> = {
  addTodo({ commit }, { text }) {
    commit('addTodo', new Todo(text, false));
  },
}

export interface TodoGetters {
  reverse: Todo[];
}

// TodoGettersの具象
export const getters: DefineGetters<TodoGetters, TodoState> = {
  reverse: (state) => state.todos.reverse(),
};

// モジュール名とアクション,ゲッターのマッピングの定義
export const todoModuleName = 'todoModule';
export interface ActionMaps {
  [todoModuleName]: TodoActions;
}
export interface GettersMaps {
  [todoModuleName]: TodoGetters;
}

const todoStore = {
  namespaced: true,
  action,
  getters,
  ....他は割愛
};

export store = new Vuex.Store({
  modules: {
    [todoModuleName]: todoStore,
  },
});

アクション,ゲッターを取得するコンポーネント
import { ActionMaps, GettersMaps, todoModuleName } from ...

export type Actions<A> = {
  // ※ <1>
  [K in keyof A]: (payload: A[K]) => Promise<any> | void
};

export type Getters<G> = {
  [K in keyof G]: G[K]
};

@Component
export default class App extends Vue {
  public getActions<K extends keyof ActionMaps>(module: K): Actions<ActionMaps[K]> {
    return Object.keys((this.$store as any)._actions).reduce((actions: any, name: string) => {
      const namespacePaths = name.split('/');

      if (namespacePaths.length === 1) {
        actions[name] = (payload: any) => this.$store.dispatch(name, payload);
      } else {
        // ※ <2>
        if (module !== namespacePaths[0]) {
           return actions;
        }
        actions[namespacePaths[1]] = (payload: any) => this.$store.dispatch(name, payload);
      }

      return actions;
    }, {});
  }

  public getGetters<K extends keyof GettersMaps>(module: K): Getters<GettersMaps[K]> {
    return Object.keys(this.$store.getters).reduce((getters: any, name: string) => {
      const namespacePaths = name.split('/');

      if (namespacePaths.length === 1) {
        getters[name] = this.$store.getters[name];
      } else {
        // ※ <2>
        if (module !== namespacePaths[0]) {
           return getters;
        }
        getters[namespacePaths[1]] = this.$store.getters[name];
      }

      return getters;
    }, {});
  }

  public main() {
    const actions = this.getActions(todoModuleName); // => Actions<TodoActions>型の変数が取得できる
    actions.addTodo({text: 'aaa'});
    actions.addTodo('invalid_arg_type'); // => コンパイルエラーになるので安全
    actions.noSuchMethod();              // => コンパイルエラーになるので安全

    const getters = this.getGetters(todoModuleName); // => Getters<TodoGetters>型の変数が取得できる
  }
}
  • ※ <1> [K in keyof A] Actions<A>の戻り値のキー<K>がAに存在するキーのみ呼び出し可能であることを保証します. つまり Actions<TodoActions> で言い換えると TodoActionsに存在するメソッドだけが呼び出し可能であり存在しない呼び出しはコンパイルエラーとなります
    また (payload: A[K]) はActions<A> の戻り値のオブジェクトであるキー<K> のバリューが関数でその関数の引数の型A[K]で定義しています. Actions<TodoActions> のキーである addTodoで言い換えると TodoActions[addTodo]である {text: string} が引数の型として定義されます.それ以外の引数を与えるとコンパイルエラーとなります

  • ※ <2> vuexでストア生成時に namespaced: true を渡すと モジュール名/アクション名, モジュール名/ゲッター名
    いう命名規則になります. モジュール名を省かないと ※<1> で述べた [K in keyof A]: (payload: A[K]) が効かなくなるため省いています

まとめ

  • 成果物はこちら https://github.com/Yama-Tomo/vue-vuex-typescript-sample

    • ステート,アクション,ゲッターへのアクセスするヘルパーはmixinへ切り出しました
    • コンテナコンポーネントでmixinを利用しステート,アクション,ゲッターを取得して子コンポーネントへはPropsで渡しています
      • そうすることで子コンポーネントでは this.$store に依存しない形で実装することができます
      • コンポーネントの階層が深くなるとPropsによるバケツリレーが増える羽目になりますが・・・
  • だいぶ硬くなり型の恩恵をうけながら実装することできるようになったと思います. typescriptの mapped typeは便利でした.

  • マッピングの定義を書かないといけないのでそれが面倒だなと思いました.それをしなくても済むスマートな実装方法があればどなたかご教授ください :bow:

26
20
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
26
20

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?