Vuexの型定義を覗いてみる〜その1〜


TypeScriptでVuexを用いた開発を行っていくと、ジェネリクスを駆使してstateなどの型情報を付与していくことになります。サンプルコードなどを参考に実装していくと、こうすればいいんだろうなという表面上の解は得られるのですが、どうしてそうしなければならないのかイマイチよくわからないまま実装していくことがしばしばあります。stateなどの補完がされることは確認できるのですが、どうしてできるのかちょっと疑問に思いました。

よくわからないまま実装するのも良くないので、今回はVuexでStoreを実装していくときにTypeScriptでどう型情報が付与されていくのかを見ていきたいと思います。

今回の調査のためのサンプルコードはこちらになります。

すべてのはじまり new Vuex.Store<RootState>()

まずStoreを作成するため new Vuex.Store<RootState>() を実行します。

// 細かい部分は省略しています
import Form, { FormState } from "./Form";
import List, { ListState } from "./List";

export interface MyRootState {
  Form: FormState;
  List: ListState;
}

const store = new Vuex.Store<MyRootState>({
  modules: {
    Form,
    List
  }
});

FormList のStoreをimportしてモジュールにセットしています。FormStateListState はStateの型情報で、次のように定義されています。

export type FormState = {
  text: string;
};

type Todo = {
  text: string;
};

export type ListState = {
  todoList: Todo[];
};

FormStateListState をもつ MyRootState を定義し、 new Vuex.Store<MyRootState>() のようにセットすることでstateの補完が効くようになります。

ここで型情報をセットすることでVSCodeなどで補完が効くことが確認できます。

MyRootState をセットしないと補完が効かないことも確認できます。

(どうでもいい話ですがジェネリクスで型情報をセットするときの正しい言葉の使い方がわかりません。ジェネリクスとして○○をセットする?)

セットされたMyRootStateがどのように働くのか

MyRootState をセットすることで型情報が付与され補完が効くことが確認できました。次にどうして補完が効くようになるのか見ていきたいと思います。

まず Vuex.Store の型がどのように実装されているのかを見てみます。

vuex/types/index.ts
export declare class Store<S> {
  constructor(options: StoreOptions<S>);

  readonly state: S;
  readonly getters: any;

  // 以下省略
}

Store で入力される型情報 S 、つまり今回わたしている MyRootState はコンストラクタで使用されている StoreOptionreadonly State などで使われていることがわかります。

readonly state: S; でStoreから参照できるstateに型情報が付与され、補完が効くようになっているようです。

ちなみに readonly とは何なのでしょうか?

TypeScriptの readonly とは任意のプロパティを読み取り専用にするためにマークできる機能で、マークしたプロパティを上書きしようとするとコンパイルエラーにしてくれます。

簡単に見ていきましたが、storeから FormList のStateが補完されるのは Store の型定義で state のプロパティに入力された MyRootState の型情報をわたしているからのようです。意外とあっさりと答えにたどり着きました。

Modulesに不正なModuleが登録されない仕組み

流石にstateの型補完だけだと内容があまりにも足りないのでmodulesでわたしているmoduleで不正なmoduleが渡らないように型で守る仕組みについて見ていきます。

ますmoduleはStoreの引数のオブジェクトにして渡されます。Storeの引数として渡されるオブジェクトは StoreOptions という型に沿ったオブジェクトとして渡す必要があります。StoreOptions の型情報は次のようになります。

vuex/types/index.ts
export interface StoreOptions<S> {
  state?: S | (() => S);
  getters?: GetterTree<S, S>;
  actions?: ActionTree<S, S>;
  mutations?: MutationTree<S>;
  modules?: ModuleTree<S>;
  plugins?: Plugin<S>[];
  strict?: boolean;
}

それぞれのプロパティに?がついています。? のついてプロパティは省略可能なプロパティです。今回のサンプルコードでは modules しか入れていませんでした。その他は ? がついているので省略可能だったわけです。

逆にここで列挙されていないプロパティを追加しようとしてもコンパイルエラーになります。

hogeというプロパティを追加しようとしてもエラーになることがわかります。

ModuleTree の型定義は次のようになります。

vuex/types/index.ts
export interface ModuleTree<R> {
  [key: string]: Module<any, R>;
}

ModuleTree の中身はキーとそれに対応する Module の型定義となっています。

なので Module を実装していない値を登録しようとするとコンパイルエラーになります。

testというプロパティに number型の1をセットするとコンパイルエラーになります。

次にModule の型定義を見ていきます。

vuex/types/index.ts
export interface Module<S, R> {
  namespaced?: boolean;
  state?: S | (() => S);
  getters?: GetterTree<S, R>;
  actions?: ActionTree<S, R>;
  mutations?: MutationTree<S>;
  modules?: ModuleTree<R>;
}

Module

  • namespaced
  • state
  • getters
  • actions
  • mutations
  • modules

のプロパティを持ちます。 それぞれのプロパティによって入る型の定義が違うので、例えばactionsプロパティに期待しない型が入るとコンパイルエラーになります。


const Sample = {
  actions: []
}

const store = new Vuex.Store<MyRootState>({
  plugins: [createLogger({})],
  modules: {
    Form,
    List,
    Sample //actionsはActionTreeを実装していないのでコンパイルエラーになる。
  },
});

VuexはTypeScriptで作られているわけではないので、素のJavaScriptのままだと不正なモジュールが登録されてもコンパイル時にわからずランタイムエラーになるのですが、型定義ファイルが定義されTypeScriptで書けるようになると、不正な値はコンパイルエラーで事前にわかるのでだいぶ開発しやすくなります。

dispatchcommit 時にはまだ課題があるのですがTypeScriptで書いて少なくともマイナスになることはないと思います。

まとめ

以上。Vuexの型定義について見ていきました。記事を書くためにVuexの型定義ファイルを眺めていたのですが、型をあとづけするのはかなり大変そうでした。 ActionTreeActionHandler あたりはかなりの苦労が伺えます。

型定義を眺めているとTypeScriptの勉強になるので、Vuex周りの型定義については引き続き眺めていこうと思います。