Vuex4のstoreにTypescriptの型を定義する（モジュールにも対応）

# はじめに

Vuex4で、storeをモジュールで構築しているとき、`useStore()`が返す値にTypescriptの型を定義する方法を共有します。
Vue3 + Vuex4を使ってChrome拡張機能を作ったときに困ったので、誰かのお役に立てれば幸いです。

https://qiita.com/taturou/items/f3e594716d174ee9bada

なお、この記事は Typescript 4.1 以降を対象としています。

# 解決したい課題

Vuex4では、storeにアクセスするために`useStore()`を使います。
そこから得られたstoreを用いてstateの値にアクセスします。
そのために `store.getters`/`store.dispatch()` を使うわけですが、その戻り値/パラメータの型を解決したいわけです。

```ts
// 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のことを考慮しています。
[公式ページ](https://next.vuex.vuejs.org/guide/typescript-support.html) を読むと、以下のように使ってね、と書いてあります。

```ts
// 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`でアクセスしたいですよね。
そのやり方もわからない。

一応、モジュールを使う場合でも名前空間を使わなければ型解決はできます。

```ts
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があるとき:

```ts
// 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などのエディタで補完が効く、コンパイラーで型チェックされる）ことが最終目標です。

```ts
// 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 })
  }
}
```

# 解決方法

解決方法と言っても、かなり無理矢理です。
本当は正規のやり方があるはずです。
が、見つけられませんでした:cry:

なので、`useStore()`の戻り値の型を定義しキャストすることで、エディタやコンパイラに型を教えることにしました。

以下のような型を定義します:

```ts
// 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`でキャストして使います:

```ts
// in a vue component
import { useStore } from 'vuex'
import { CounterStore } from './store'

export default {
  setup () {
    const store = useStore() as CounterStore // キャスト
  }
}
```

これで解決できました。
VSCodeでパラメータがちゃんと補完され、間違った型の値を代入するとコンパイルエラーが発生します:wink:

# 型を自動生成する

これで解決！
と言っても、`CounterStore`型を手動で定義するのだと嬉しくないです。
Counterモジュールの各インタフェースの変更に同期して、こちらの型も正しく変更する必要があります。
変更漏れやタイプミスなどが必ず発生しますし、なにより面倒です。美しくない。

## type StoreModuleType<T>

そこで、各インタフェースからStore型を生成するジェネリック型を定義しました。

https://github.com/taturou/vue3-typescript-chrome-extension/blob/8723a039348fd1df942edafbfe339c87533d7be7/src/lib/store/_types.ts#L141

上記はエラー処理などが入っているので、以下に余分なものを削ったものを抜粋します:

```ts
// 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'>
```

以下のように使います:

```ts
// 'counter' は createStore() するときに付けた名前空間（モジュール名）に合わせる必要がある
export type CounterStore = StoreModuleType<
                             'counter',
                             CounterState,
                             CounterGetters,
                             CounterMutations,
                             CounterActions>
```

これで、`useStore()`の戻り地をキャストするための`CounterStore`型を定義できます。
簡単♪

### getters 解説

```ts
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) に関しては以下の記事がわかりやすいので参照してください:

https://qiita.com/Quramy/items/e27a7756170d06bef22a#mapped-types

`ReturnType<T>` に関しては以下の記事がわかりやすいです:

https://qiita.com/k-penguin-sato/items/e2791d7a57e96f6144e5#returntypet

そして、`as` ですが、Typescript 4.1 で追加された機能です。
`in keyof` の左辺 `K` を、別の値や型に re-map（日本語だと「再写像」？）できます。
この機能は Typescript 4.1 のリリースアナウンスに記載されています:

https://devblogs.microsoft.com/typescript/announcing-typescript-4-1/

引用:

> That’s why TypeScript 4.1 allows you to re-map keys in mapped types with a new <font color=red>as</font> clause.
> (略)
> With this new <font color=red>as</font> clause, you can leverage features like template literal types to easily create property names based off of old ones.
> 
> ```ts
> 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` の定義に戻って見てみましょう:

```ts
// 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()`だけ解説します

```ts
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` に関しては、やはり以下の記事がわかりやすいので参照してください:

https://qiita.com/Quramy/items/e27a7756170d06bef22a#tk

`Parameters<T>` と `ReturnType<T>` に関しても、やはり以下の記事を参照してください:

https://qiita.com/k-penguin-sato/items/e2791d7a57e96f6144e5#parameterst

少し面白いのは以下ですね。

```ts
    payload?: Parameters<A[K]>[1]
```

MutationsとActionsは第1引数は決まっていますが、第2引数に任意の値を取ることができます。
そして、それが`store.commit()`と`store.dispatch()`の第2引数として認識されます。
パラメータはその2つで（厳密には違いますが）、複数の値を渡したいときは第2引数にObjectを渡す決まりです。
つまり、`payload`の部分は「インタフェースの第2引数」と決め打ちすることができます。
`Parameters<T>` は引数の型をタプルで返すので、第2引数である `[1]` を設定しています。

もろもろ分かったところで、`dispatch` の定義に戻って見てみましょう:

```ts
// 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モジュールにも対応することができました。

本物のコードは、もう少し条件分岐などが入っているので、もし興味のある方がいらっしゃいましたら、見て指摘などしていただけると幸いです。

あと、「いや、公式のやりかたでこうやればよいよ」ってのがあれば教えていただけると幸いです。