4
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2021-03-30

はじめに

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 })
  }
}

解決方法

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

なので、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でパラメータがちゃんと補完され、間違った型の値を代入するとコンパイルエラーが発生します:wink:

型を自動生成する

これで解決!
と言っても、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 keyofasReturnType<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 keyofasReturnType<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
//     }
//   }

Tstring型であることを明示しているため、${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 keyofParameters<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モジュールにも対応することができました。

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

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

4
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?