TypeScript 2.2.0が正式にリリースされた時点で Vuex 本体の型定義にも何らかの修正が入る可能性がありますし、情報としての賞味期限は短そうですが、現時点で keyof
を Vuex で利用するためのプラクティス的なあれ。
keyof
に関する詳細はこちらの記事をどうぞ
TypeScript 2.1 で導入される keyof
を使って EventEmitter
を定義してみる
元のコード
Vuexの公式ドキュメントに載っているこのコードを材料にします。
const store = new Vuex.Store({
state: { count: 0 },
mutations: {
increment (state) {
state.count++
}
},
actions: {
increment (context) {
context.commit('increment')
}
}
})
と言いつつ、あまりにもさびしいのでいくらかコードを追加。
const store = new Vuex.Store({
state: { count: 0, message: "" },
mutations: {
increment (state) {
state.count++;
},
setMessage (message) {
state.message = message;
}
},
actions: {
increment (context) {
context.commit('increment');
},
hello (context, name) {
context.commit('setMessage', "Hello, " + name);
}
greet (context, { greeting, name }) {
context.commit('setMessage', greeting + ", " + name);
}
}
})
目標
以下のことを、コンパイル時に静的にチェックできるようにする。
-
state
のメンバー名と型 -
mutations
およびactions
内に、必要なメンバーがすべて記述されていること -
mutations
内での操作で、stateのプロパティ名の間違いや型のミスマッチがないこと - コンポーネントから
store.dispatch()
やstore.commit()
を呼ぶときに、typeの間違いや引数(Payload)の型の間違いがないこと- 例えば、
store.dispatch('greeet', "John")
やstore.dispatch('greet', 0)
をコンパイルエラーとして検出する
- 例えば、
また、Visual Studio Codeなどを使った時に、以下の箇所で入力補完が効くようにする。
-
mutations
およびactions
配下のメンバー名 - コンポーネントから
store.dispatch()
やstore.commit()
を呼ぶときのtype名(第一引数) -
mutations
配下の実装でstate
を操作するときのメンバー名 -
actions
配下の実装でcontext.commit()
を呼ぶときのtype名
例えば、こんな感じ ↓
actions: {
increment (context) {
context.commit('|/* ここで(increment|setMessage)を補完 */')
},
|/* ここで(hello|greet)を補完 */
}
Step By Step
stateの型付け
Vuexの型定義ファイルに従って普通にTypeScript化するとこんな感じです。
stateが型付けされるので、mutations内でのstate操作で補完やコンパイラチェックが効くようになります。
interface MyState {
count: number;
message: stirng;
}
const store = new Vuex.Store<MyState>({
state: { count: 0, message: "" },
mutations: {
increment (state) {
state.count++;
},
setMessage (state, message) {
state.message = message;
}
},
actions: {
// 省略
}
});
Payload の型を定義
個々のmutationとactionの名前と、引数として渡すオブジェクトの型をインターフェイスで定義します。
このインターフェイスを元に、 mutations
, actions
, dispatch
, commit
を片付けしていきます。
// 各mutationのpayloadの型
interface MyMutationPayloads {
increment: undefined; // 引数なしは undefined とか null で表現
setMessage: string;
}
// 各actionのpayloadの型
interface MyActionPayloads {
increment: undefined;
hello: string;
greet: { greeting: string, name: string };
}
mutations
と actions
の型付け
上で定義したインターフェイスを使うと、 mutations の型(MyMutations
)は以下のように定義できます。
(ただしもっとシンプルに定義できる方法があるかもしれない)
// 個々のmutationの型
type MyMutationHandler<K extends keyof MyMutationPayloads> = (state: MyState, payload: MyMutationPayloads[K]) => void;
// mutationsの型
type MyMutations = { [K in keyof MyMutationPayloads]: MyMutationHandler<K> };
これで、以下のように定義すれば、 increment
や setMessage
の部分は補完が効くし、間違えたり足らなかったりしたら
コンパイラがエラーにしてくれるようになります。
const mutations: MyMutations = {
increment(state, _) {
state.count++;
},
setMessage(state, message) {
state.message = message;
}
}
引数のない mutation は定義できなくなるので increment
の方にはダミーの引数を足す必要がありますが、まあささいな問題かと。(MyMutationHandler
の定義で payload
を payload?
にすれば引数なしにできるようになりますが、逆に引数必須な mutation に対しても引数のないfunctionを突っ込めるようになるので、良し悪しかなあという感じ)
actions
も同じ要領で。
type MyActionContext = Vuex.ActionContext<AppState, AppState>;
type MyActionHandler<K extends keyof MyActionPayloads> = (context: MyActionContext, payload: MyActionPayloads[K])
type MyActions<K extends keyof MyActionPayloads> = { [K in keyof MyActionPayloads]: MyActionHandler<K> };
const actions: MyActions = {
increment(context) {
context.commit('increment');
},
hello(context, name) {
context.commit('setMessage', "Hello, " + name);
}
greet(context, { greeting, name }) {
context.commit('setMessage', greeting + ", " + name);
}
}
dispatch
と commit
の型付け
Vuexの型定義では、dispatch
メソッドの型 Dispatch
は以下のように定義されています。
export interface Payload {
type: string;
}
export interface Dispatch {
(type: string, payload?: any): Promise<any[]>;
<P extends Payload>(payloadWithType: P): Promise<any[]>;
}
以下のように第一引数にtypeを指定するパターンと、typeをメンバとしてもつオブジェクトを渡すパターンの2通りの使い方ができるわけですね。
dispatch('greet', { greeting: 'Hello', name: 'vue' });
dispatch({ type: 'greet', greeting: 'Hello', name: 'vue' });
この Dispatch
の定義と互換性を保ちつつ、MyActionPayloads
を使ってより厳密に型付けした MyDispatch
型を定義します。
type ActionPayloadWithType<K extends keyof MyActionPayloads, V extends MyActionPayloads[K]> = { type: K } & V;
interface MyDispatch {
<K extends keyof MyActionPayloads>(type: K, payload: MyActionPayloads[K]): Promise<any[]>;
<K extends keyof MyActionPayloads, V extends MyActionPayloads[K]>(payloadWithType: ActionPayloadWithType<K, V>): Promise<any[]>;
}
commit
も同じく。
type MutationPayloadWithType<K extends keyof MyMutationPayloads, V extends MyMutationPayloads[K]> = { type: K } & V;
interface MyCommit {
<K extends keyof MyMutationPayloads>(type: K, payload: MyMutationPayloads[K], options?: Vuex.CommitOptions): void;
<K extends keyof MyMutationPayloads, V extends MyMutationPayloads[K]>(payloadWithType: MutationPayloadWithType<K, V>, options?: Vuex.CommitOptions): void;
dispatch
、commit
を持つのはStoreとActionContextなので、それらの型を再定義。
interface MyStore extends Vuex.Store<MyState> {
dispatch: MyDispatch;
commit: MyCommit;
}
interface MyActionContext extends Vuex.ActionContext<MyState, MyState> {
dispatch: MyDispatch;
commit: MyCommit;
}
あとは、コンポーネントを vue-class-component
とかで定義するときに $store
の型を MyStore
として定義してやれば、
dispatch
や commit
の呼び出し時の引数がコンパイラによってチェックされるようになります。
@component
class MyComponent {
$store: MyStore;
onClick(e) {
// 'greet' の部分でtypoしてたり、引数の型が合わなかったりしたらコンパイルエラーになる
this.$store.dispatch('greet', { greeting: "こんにちは", name: "vue" });
}
}
action
の中から context.commit()
や context.dispatch()
を呼ぶ場合も同様。
※ただし、action名/mutation名の部分については、現状補完が効きません。これについてissueをあげたら2.2.0のマイルストーンに入ったので、2.2.0が出てくる時にはできるようになっていると思いますが、とりあえず現段階では、こんな感じでオーバーロードを追加しておけば補完できるはず。
interface MyDispatch {
(type: keyof MyActionPayloads, payload: never); // ← 追加
<K extends keyof MyActionPayloads>(type: K, payload: MyActionPayloads[K]): Promise<any[]>;
<K extends keyof MyActionPayloads, V extends MyActionPayloads[K]>(payloadWithType: ActionPayloadWithType<K, V>): Promise<any[]>;
というところでひとまずおしまい。