小さなエンドウ豆

まだまだいろいろ勉強中

Vuex の型定義全部書いてみた

Vuex の型定義全部書いてみた

Vuex + TypeScript での問題点

  • getters の型定義が any
  • mutations / actions の payload が any 型

payload とは以下のような mutation があったとすると、第一引数が state オブジェクトでそれ以外の引数のことを言います。

mutations: {
  increment (state, n) {
    state.count += n
  }
}

これから具体的な例をもとに深ぼっていきます。

getters の定義が any

例えば getter 関数の第2引数 getters を利用することで他の getter 関数への参照を持つことが出来ます。
ただ getters 自身が any 型となってしまうためコンパイル時に型が違う点に気づくことが出来ません。

state: {
  name: null as string | null
},

getters: {
  getName(state) {
    return state.name
  },
  greet(state, getters) {
    return `My name is ${getters.getName.toUpperCase()}.`
    // ここでインラインアサーションを使うとアンチパータン
    // 実装と型が違うケースがあるため
    // 例: return `My name is ${(getters.getName as string).toUpperCase()}.`
  }
}

payload のスキーマ間違え

mutation, action 関数の payload は any 型です。
そのため型アノテーションを付けたとしても実行側はこのアノテーションに関与しないためダウンキャストとなってしまいます。

mutations: {
  setName(state, payload: string) { // ダウンキャスト
    state.name = payload
  }
},
actions: {
  asyncSetName(ctx, payload) {
    ctx.commit('setName', {name: payload}) // スキーマ違い
  }
}

存在しない action 関数の dispatch

これはあるあるかもですね。
commit と dispatch を間違える場合も考えられます。

これらも TypeScript で検知したいところ。

解決へのアプローチ①: 公式型定義を使う

Vuex の types の中に型を見ると以下のようになっている。

export type Getter<S, R> = (state: S, getters: any, rootState: R, rootGetters: any) => any;
export type Action<S, R> = ActionHandler<S, R> | ActionObject<S, R>
export type Mutation<S> = (state: S, payload: any) => any;

export interface GetterTree<S, R> {
  [key: string]: Getter<S, R>;
}

export MutationTree<S> {
  [key: string]: Mutaion<S>;
}

export ActionTree<S, R> {
  [key: string]: Action<S, R>;
}

state の型が総称型になっているため実装側で指定が可能です。
[key] { process } みたいな形で積み上げることによって store インスタンスの方を表現することが出来ます。
またインデックスシグネチャを使うとすべての関数の第一引数に State 型を付与出来ます。

ただ、依然として getters が any だったり、 mutation, action の payload が any であるため解決したとは言えません。
完全に解決するためには独自に型を作って付与していくしかなさそうです。

getters の型を解決する

予め getters の要件を interface で明示しておきます。
また Getter 型を再定義することで getters の interface を利用します。

interface IGetters {
  double: number
  expo2: number
  expo: (amount: number) => number
}

type Getters<S, G> = {
  [K in keyof G]: (state: S, getters: G) => G[K]
}

ここで in

mutations の型を解決する

Getters と同じように interface を用意します。
ここで用意するのは payload に対する interface です。

interfece IMutations {
  setCount: { amout: number },
  increment: void
}

type Mutations<S, M> = {
  [K in keyof M]: (state: S, payload: M[K]) => void
}

const mutations: Mutations<State, IMutation> = {
  setCount(state, payload) => {...},
  increment(state) => {...}
}

actions の型を解決する

action 関数はこれまで定義した getters と mutations への参照があります。
まず interfece を定義するが、これは mutations と同じように payload に対して定義します。
次に Actions の型を定義するのですが、action 関数では第一引数に ctx が渡されるため Context 型を作成します。
これには store 内のすべてのオブジェクトを型として定義する必要があります。
また commit や dispatch も以下のように表すことができます。

interface IActions {
  asyncSetCount: { amount: number },
  asyncIncrement: void
}

// Promise オブジェクトを返す関数もあるので any とする
type Actions<S, A, G={}, M={}> = {
  [K in keyof A]: (ctx: unknown, payload: A[K]) => any
}

type Context<S, A, G, M, RS, RG> = {
  commit: Commit<M>,
  dispatch: Dispatch<A>,
  state: S,
  getters: G
}

type Commit<M> = <T extends keyof M>(type: T, payload?: M[T]) => void

type Dispatch<A> = <T extends keyof A>(type: T, payload?" A[T]) => any

const actions: Actions<State, IActions, IGetters, IMutations> = {
  asyncSetCount(ctx, payload) {
    ctx.commit('setCount', { amount: payload.amount })
  },
  asyncIncrement(ctx) {
    ctx.commit('increment')
  }

定義してきた型を別ファイルで管理してもいいかもです。

まとめ

Vuex に型をつけたい一心で調べた公式が提供している型だけだと不十分だということがわかりました。
ここまでして型をつけるかは費用対効果を考えなくてはいけませんね。。
Decorator を使って module ライクに Vuex を書き換える方法もあるので次はそれを記して行きたいと思います。

参考: 実践TypeScript ~ BFFとNext.js&Nuxt.jsの型定義~ https://www.amazon.co.jp/%E5%AE%9F%E8%B7%B5TypeScript-BFF%E3%81%A8Next-js-Nuxt-js%E3%81%AE%E5%9E%8B%E5%AE%9A%E7%BE%A9-%E5%90%89%E4%BA%95-%E5%81%A5%E6%96%87/dp/483996937X/ref=sr_1_1?__mk_ja_JP=%E3%82%AB%E3%82%BF%E3%82%AB%E3%83%8A&keywords=typescript&qid=1579435873&sr=8-1