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
}
});
Form
と List
のStoreをimportしてモジュールにセットしています。FormState
と ListState
はStateの型情報で、次のように定義されています。
export type FormState = {
text: string;
};
type Todo = {
text: string;
};
export type ListState = {
todoList: Todo[];
};
FormState
と ListState
をもつ MyRootState
を定義し、 new Vuex.Store<MyRootState>()
のようにセットすることでstateの補完が効くようになります。
ここで型情報をセットすることでVSCodeなどで補完が効くことが確認できます。
MyRootState
をセットしないと補完が効かないことも確認できます。
(どうでもいい話ですがジェネリクスで型情報をセットするときの正しい言葉の使い方がわかりません。ジェネリクスとして○○をセットする?)
セットされたMyRootStateがどのように働くのか
MyRootState
をセットすることで型情報が付与され補完が効くことが確認できました。次にどうして補完が効くようになるのか見ていきたいと思います。
まず Vuex.Store
の型がどのように実装されているのかを見てみます。
export declare class Store<S> {
constructor(options: StoreOptions<S>);
readonly state: S;
readonly getters: any;
// 以下省略
}
Store
で入力される型情報 S
、つまり今回わたしている MyRootState
はコンストラクタで使用されている StoreOption
や readonly State
などで使われていることがわかります。
readonly state: S;
でStoreから参照できるstateに型情報が付与され、補完が効くようになっているようです。
ちなみに readonly
とは何なのでしょうか?
TypeScriptの readonly
とは任意のプロパティを読み取り専用にするためにマークできる機能で、マークしたプロパティを上書きしようとするとコンパイルエラーにしてくれます。
簡単に見ていきましたが、storeから Form
や List
のStateが補完されるのは Store
の型定義で state
のプロパティに入力された MyRootState
の型情報をわたしているからのようです。意外とあっさりと答えにたどり着きました。
Modulesに不正なModuleが登録されない仕組み
流石にstateの型補完だけだと内容があまりにも足りないのでmodulesでわたしているmoduleで不正なmoduleが渡らないように型で守る仕組みについて見ていきます。
ますmoduleはStoreの引数のオブジェクトにして渡されます。Storeの引数として渡されるオブジェクトは StoreOptions
という型に沿ったオブジェクトとして渡す必要があります。StoreOptions
の型情報は次のようになります。
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
の型定義は次のようになります。
export interface ModuleTree<R> {
[key: string]: Module<any, R>;
}
ModuleTree
の中身はキーとそれに対応する Module
の型定義となっています。
なので Module
を実装していない値を登録しようとするとコンパイルエラーになります。
test
というプロパティに number型の1をセットするとコンパイルエラーになります。
次にModule
の型定義を見ていきます。
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で書けるようになると、不正な値はコンパイルエラーで事前にわかるのでだいぶ開発しやすくなります。
dispatch
や commit
時にはまだ課題があるのですがTypeScriptで書いて少なくともマイナスになることはないと思います。
まとめ
以上。Vuexの型定義について見ていきました。記事を書くためにVuexの型定義ファイルを眺めていたのですが、型をあとづけするのはかなり大変そうでした。 ActionTree
や ActionHandler
あたりはかなりの苦労が伺えます。
型定義を眺めているとTypeScriptの勉強になるので、Vuex周りの型定義については引き続き眺めていこうと思います。