はじめに
Vuex4で、storeをモジュールで構築しているとき、useStore()
が返す値にTypescriptの型を定義する方法を共有します。
Vue3 + Vuex4を使ってChrome拡張機能を作ったときに困ったので、誰かのお役に立てれば幸いです。
なお、この記事は Typescript 4.1 以降を対象としています。
解決したい課題
Vuex4では、storeにアクセスするためにuseStore()
を使います。
そこから得られたstoreを用いてstateの値にアクセスします。
そのために store.getters
/store.dispatch()
を使うわけですが、その戻り値/パラメータの型を解決したいわけです。
// in a vue component
import { useStore } from 'vuex'
export default {
setup () {
const store = useStore() // $storeではなくuseStore()で取得する
const value = store.getters.foo // fooの型は?
store.dispatch('bar', value) // 'bar'は有効? valueの型は?
}
}
Vue3のエコシステムはTypescriptフレンドリーを謳っているので、もちろんVuex4もTypescriptのことを考慮しています。
公式ページ を読むと、以下のように使ってね、と書いてあります。
// store.ts
import { InjectionKey } from 'vue'
import { Store } from 'vuex'
export interface State {
count: number
}
export const key: InjectionKey<Store<State>> = Symbol() // Stateのキーと型の一覧
// in a vue component
import { useStore } from 'vuex'
import { key } from './store'
export default {
setup () {
const store = useStore(key) // keyを設定する
const value = store.state.count // number型だとわかる
}
}
確かに型はわかる。
わかるけど、、、
store.dispatch()
は?
Actionsは必ずしもStateのプロパティと一致しているわけではない -- State.count
を更新するためにincrement
アクションが定義されているかもしれない -- ので、このkey
だと力不足です。
それに、この方法だとモジュールを使ったときのやり方がわかりません。
Counterモジュールにcountプロパティがあれば、store.state.counter.count
でアクセスしたいですよね。
そのやり方もわからない。
一応、モジュールを使う場合でも名前空間を使わなければ型解決はできます。
interface CounterState {
counterCount: number, // プロパティ名で名前空間を解決する
counterFoo: ...,
counterBar: ...
}
const CounterModule = {
namespaced: false, // 名前空間を使わない
state: CounterState = {...}
}
const store = createStore({
modules: {
counter: CounterModule
}
}
こうすれば、全てのモジュールがフラットな名前空間に並ぶので、key
により型解決ができます。
でもコレジャナイでしょ。
store.state.counterCount
ではなく store.state.counter.count
を使いたいです。
どちらにしろdispach()
に関しては解決してないし。
改めて解決したい課題
このようなstoreがあるとき:
// store.ts
interface CounterState {
count: number
}
interface CounterGetters {
count (state: CounterState): number
}
interface CounterMutations {
setCount (state: CounterState, payload: { value: number }): void
}
interface CounterActions {
async setCount ({ commit, state }, payload: { value: number }): Promise<void>
}
const CounterModule = {
namespaced: true, // 俺は名前空間を使うぞ!
state : { ... } as CounterState,
getters : { ... } as CounterGetters,
mutations: { ... } as CounterMutations,
actions : { ... } as CounterActions
}
export const store = createStore({
modules: {
counter: CounterModule // 'counter' が名前空間
}
}
以下のような処理で、ちゃんと型が解決される(VSCodeなどのエディタで補完が効く、コンパイラーで型チェックされる)ことが最終目標です。
// in a vue component
import { useStore } from 'vuex'
export default {
setup () {
const store = useStore()
// getters に 'counter/count' プロパティがあることがわかる
// 戻り値が number 型であることがわかる
const value = store.getters['counter/count']
// dispatch の第1パラメータに 'counter/setCount' が設定可能であることがわかる
// 第2パラメータが {value: number} 型であることがわかる
store.dispatch('counter/setCount', { value: 1 })
}
}
解決方法
解決方法と言っても、かなり無理矢理です。
本当は正規のやり方があるはずです。
が、見つけられませんでした
なので、useStore()
の戻り値の型を定義しキャストすることで、エディタやコンパイラに型を教えることにしました。
以下のような型を定義します:
// store.ts
export type CounterStore = {
getters: {
"counter/count": number
}
}
& {
commit(
key: 'counter/setCount',
payload: {
value: number
},
options?: CommitOptions
): void
}
& {
dispatch(
key: `counter/setCount`,
payload: {
value: number
},
options?: DispatchOptions
): Promise<void>
}
そして、使う側はuseStore()
の戻り値をCounterStore
でキャストして使います:
// in a vue component
import { useStore } from 'vuex'
import { CounterStore } from './store'
export default {
setup () {
const store = useStore() as CounterStore // キャスト
}
}
これで解決できました。
VSCodeでパラメータがちゃんと補完され、間違った型の値を代入するとコンパイルエラーが発生します
型を自動生成する
これで解決!
と言っても、CounterStore
型を手動で定義するのだと嬉しくないです。
Counterモジュールの各インタフェースの変更に同期して、こちらの型も正しく変更する必要があります。
変更漏れやタイプミスなどが必ず発生しますし、なにより面倒です。美しくない。
type StoreModuleType
そこで、各インタフェースからStore型を生成するジェネリック型を定義しました。
上記はエラー処理などが入っているので、以下に余分なものを削ったものを抜粋します:
// Store型を生成するためのジェネリック型
//
// T: 名前空間(モジュール名)(例: 'counter')
// S: Stateインタフェース
// G: Gettersインタフェース
// M: Mutationsインタフェース
// A: Actionsインタフェース
export type StoreModuleType<T extends string, S, G, M, A> = {
getters: {
[K in keyof G as `${T}/${K}`]: ReturnType<G[K]>
}
}
& {
commit<
K extends keyof M
>(
key: `${T}/${K}`,
payload?: Parameters<M[K]>[1]
options?: CommitOptions
): ReturnType<M[K]>
}
& {
dispatch<
K extends keyof A
>(
key: `${T}/${K}`,
payload?: Parameters<A[K]>[1]
options?: DispatchOptions
): ReturnType<A[K]>
}
& Omit<baseStore<S>, 'getters' | 'commit' | 'dispatch'>
以下のように使います:
// 'counter' は createStore() するときに付けた名前空間(モジュール名)に合わせる必要がある
export type CounterStore = StoreModuleType<
'counter',
CounterState,
CounterGetters,
CounterMutations,
CounterActions>
これで、useStore()
の戻り地をキャストするためのCounterStore
型を定義できます。
簡単♪
getters 解説
export type StoreModuleType<T extends string, _, G, _, _> = {
getters: {
[K in keyof G as `${T}/${K}`]: ReturnType<G[K]>
}
} & (略)
キモは in keyof
、as
、ReturnType<T>
です。
in keyof
(Mapped types) に関しては以下の記事がわかりやすいので参照してください:
ReturnType<T>
に関しては以下の記事がわかりやすいです:
そして、as
ですが、Typescript 4.1 で追加された機能です。
in keyof
の左辺 K
を、別の値や型に re-map(日本語だと「再写像」?)できます。
この機能は Typescript 4.1 のリリースアナウンスに記載されています:
引用:
That’s why TypeScript 4.1 allows you to re-map keys in mapped types with a new as clause.
(略)
With this new as clause, you can leverage features like template literal types to easily create property names based off of old ones.type Getters<T> = { [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K] }; interface Person { name: string; age: number; location: string; } type LazyPerson = Getters<Person>;
in keyof
、as
、ReturnType<T>
が分かったところで、getters
の定義に戻って見てみましょう:
// Store型を生成するためのジェネリック型
export type StoreModuleType<T extends string, _, G, _, _> = {
getters: {
[K in keyof G as `${T}/${K}`]: ReturnType<G[K]>
}
} & (略)
// Gettersインタフェース
interface CounterGetters {
count (state: CounterState): number
}
// 生成されたStore型
export type CounterStore = StoreModuleType<'counter', _, CounterGetters, _, _>
// same as
// type CounterStore = {
// getters: {
// 'counter/count': number
// }
// }
T
はstring
型であることを明示しているため、${T}
のように文字列変数として利用することができます。
つまり、getters
は
- プロパティ名として、
CounterGetters
インタフェースの各プロパティ名の先頭に 'counter/' を足した文字列を、 - その型として、
CounterGetters
インタフェースの各プロパティの戻り値の型を、設定したオブジェクト。
です。
commit()/dispatch() 解説
commit()
とdispatch()
は同じなのでdisptch()
だけ解説します
export type StoreModuleType<T extends string, _, _, _, A> = {
(略)
}
& {
dispatch<
K extends keyof A
>(
key: `${T}/${K}`,
payload?: Parameters<A[K]>[1]
options?: DispatchOptions
): ReturnType<A[K]>
}
キモは extends keyof
、Parameters<T>
、ReturnType<T>
です。
extends keyof
に関しては、やはり以下の記事がわかりやすいので参照してください:
Parameters<T>
と ReturnType<T>
に関しても、やはり以下の記事を参照してください:
少し面白いのは以下ですね。
payload?: Parameters<A[K]>[1]
MutationsとActionsは第1引数は決まっていますが、第2引数に任意の値を取ることができます。
そして、それがstore.commit()
とstore.dispatch()
の第2引数として認識されます。
パラメータはその2つで(厳密には違いますが)、複数の値を渡したいときは第2引数にObjectを渡す決まりです。
つまり、payload
の部分は「インタフェースの第2引数」と決め打ちすることができます。
Parameters<T>
は引数の型をタプルで返すので、第2引数である [1]
を設定しています。
もろもろ分かったところで、dispatch
の定義に戻って見てみましょう:
// Store型を生成するためのジェネリック型
export type StoreModuleType<T extends string, _, _, _, A> = {
(略)
}
& {
dispatch<
K extends keyof A
>(
key: `${T}/${K}`,
payload?: Parameters<A[K]>[1]
options?: DispatchOptions
): ReturnType<A[K]>
}
// Actionsインタフェース
interface CounterActions {
async setCount ({ commit, state }, payload: { value: number }): Promise<void>
}
// 生成されたStore型
export type CounterStore = StoreModuleType<'counter', _, _, _, CounterActions>
// same as
// type CounterStore = {
// dispatch(
// key: 'counter/setCount',
// payload?: { value: number },
// options?: DispatchOptions
// ): Promise<void>
// }
つまり、dispatch()
は
- パラメータ
key
として、CounterActions
インタフェースの各プロパティ名の先頭に 'counter/' を足した文字列を、 - パラメータ
payload
の型として、CounterActions
インタフェースの各プロパティの第2引数を、 - パラメータ
options
の型として、DispatchOptions
を、 - 戻り値の型として、
CounterGetters
インタフェースの各プロパティの戻り値の型を、設定した関数。
です。
まとめ
Vuex4のuseStore()
が返す値にTypescriptの型を定義する方法を共有しました。
公式の方法ではなく、Getters/Mutatoins/Actoinsの各インタフェースから、store.getters
/store.commit()
/store.dispatch()
の型を生成し、useStore()
の戻り値をその型でキャストする方法を用いました。
また、型は、各インタフェースから自動で生成できるようにしました。
それにより、Vuexモジュールにも対応することができました。
本物のコードは、もう少し条件分岐などが入っているので、もし興味のある方がいらっしゃいましたら、見て指摘などしていただけると幸いです。
あと、「いや、公式のやりかたでこうやればよいよ」ってのがあれば教えていただけると幸いです。