業務委託で STORES の開発をしている @inouetakuya です。
下記の記事(2020/09/14)にあるように、STORES では TypeScript の導入を進めています。
現状、新規に記述するコードについては TypeScript で書いていっているのですが、既存のコードではまだ JavaScript のままになっているものも残っています。
今回の記事ではそれらの既存のコードのうち、Vuex ストアに関するコードをどのように TypeScript で書き換えていったかをお伝えしたいと思います。
環境
対象とした環境は下記のとおりです。
- TypeScript v3.9.7
- NuxtJS v2.14.5
- Vue.js v2.6.12
- Vuex v3.5.1
- Vuex Type Helper(後述)v1.2.2
解決したい問題の整理
Vuex ストアに関するコードを TypeScript で書き換えるにあたって、まずはじめにチームで行ったのは「われわれは、どのような問題を解決したいのか?」の整理でした。
Vuex ストア内での型付け
Vuex が公式で提供している GetterTree、ActionTree、MutationTree を使っても、
- ゲッターの型が any になる
- ゲッターの戻り値の型が any になる
- アクションとミューテーションの payload の型が any になる
などの問題が残ります(対象となるコードは下記)
- https://github.com/vuejs/vuex/blob/v3.5.1/types/index.d.ts#L136-L146
- https://github.com/vuejs/vuex/blob/v3.5.1/types/index.d.ts#L118-L120
これらに型を付けることで、より型安全なコードを書きたいというモチベーションがありました。
Vue コンポーネントから Vuex ストア利用時の型付け
また、Vue コンポーネントから Vuex ストアを利用した際にも下記を実現したいと考えました。
- ゲッターの戻り値の型を付けたい
- アクションとミューテーションの payload の型を付けたい
- アクション名やミューテーション名の文字列に誤りがあれば、コンパイル時にエラーになってほしい
Vue 3 / Vuex 4 / Nuxt 3 への移行コストを上げたくない
さらに、時期的な問題も確認しました。
つまり、2020年9月に Vue.js v3 がリリースされ、また Vuex や NuxtJS もメジャーバージョンアップが予定されています。そうすると、現在の Vue / Vuex のコードの書き方が今後変更を余儀なくされます。
そのときに TypeScript の型付けが足枷になってほしくない、逆に言うと、足枷にならない方法を選択する必要があるということを確認しました。
解決するための選択肢
さて、上で見てきた問題を解決するためのアプローチとして、いくつかのものがあります。
クラスベース
最も人気のあるアプローチの 1つ
として、クラスベースでデコレーターを使って型を付けるライブラリを利用するというものです。
Vuex をラップしたライブラリを使う
また Vuex ストアに型付けしやすいように Vuex をラップしたライブラリを利用するという選択肢もあります。
Vanilla
そして最後に、自前でゲッターの戻り値の型や、アクションとミューテーションの payload の型を定義し、それらを使うというアプローチです。
『実践 TypeScript』で紹介されているのはこの方法であり、また Vuex Type Helper というライブラリはこれを実現するためのヘルパーを提供してくれています。
選択肢の検討
上に挙げた選択肢はいずれも
- Vuex ストア内での型付け
- Vue コンポーネントから Vuex ストア利用時の型付け
は実現できるのですが、
- Vue 3 / Vuex 4 / Nuxt 3 への移行コスト
という点で評価が分かれました。
具体的には Vuex 4 から Vue 3 の Composition API の対応が入り、useStore などの関数を用いて型が付いたストアを利用することができます。
(コードのイメージは こちらの記事 が分かりやすいと思います)
Vuex 本体がアップデートして、そのような書き方に移行しようとした際に、Vuex まわりのコードが素の Vuex のコードと離れていると、戻すときのコストが高くなりそうだと考え、
- クラスベース
- Vuex をラップしたライブラリを使う
というアプローチは見送りました。
一方で、素の Vuex のコードと離れずに型を付けていくにせよ、何らかのヘルパーはあったほうが良いと考え、また、そのヘルパーは捨てる前提で捉えていたほうが良いと考えました。
そうしたヘルパーを自前で用意するか、何らかライブラリを探すか、と模索していたところ Vuex Type Helper が用途にマッチしたので、それを採用するに至りました。
これは PoC で書かれたもので活発にメンテナンスされているものではありませんが、とても小さなライブラリなので、必要になれば内製に切り替えてもよいかと考えています。
また、厳密には Vuex Type Helper だけでは「アクション名やミューテーション名の文字列に誤りがあればコンパイル時にエラーになってほしい」という要件は満たせないのですが、アクション名やミューテーション名の文字列が誤っていれば、コンパイル時にはエラーにならないものの、ランタイムエラーにはなるので、実装後の動作確認時に気付けるだろうと判断し、許容することにしました。
どのように書き換えていっているか?
map ヘルパーメソッドの書き換え
さて、実際の書き換えのステップとしては、Vuex Type Helper の導入より前に mapState、mapGetters、mapActions、mapMutations といったヘルパーメソッドの書き換えから行いました。
map ヘルパーメソッドを経由して Vue コンポーネントに結び付けられたものは、コンパイラが型を検知できず any 型として扱われてしまうためです。
- mapState を使っている箇所はゲッターに置き換えて this.$store.getters 経由でアクセスする
- mapGetters、mapActions、mapMutations を使っている箇所も this.$store 経由でアクセスする
ように書き換えました。
書き換えにあたっては vuex-map-purge を利用させてもらいました。
Vuex Type Helper を利用した型付け
map ヘルパーメソッドの書き換えが終わった後、Vuex ストアの型付けに着手しました。
Vuex Type Helper を使って、下記のように型を付けていっています(README からの抜粋を用いたイメージです)
// types/store/counter.ts export interface CounterState { count: number } export interface CounterGetters { // ゲッター名と戻り値の型を列挙します half: number } export interface CounterMutations { // ミューテーション名と payload の型を列挙します inc: { amount: number } } export interface CounterActions { // アクション名と payload の型を列挙します incAsync: { amount: number delay: number } }
// store/counter.ts import * as Vuex from 'vuex' import { DefineGetters, DefineMutations, DefineActions, } from 'vuex-type-helper' const state: CounterState = { count: 0 } const getters: DefineGetters<CounterGetters, CounterState> = { half: state => state.count / 2 } const mutations: DefineMutations<CounterMutations, CounterState> = { inc (state, { amount }) { state.count += amount } } const actions: DefineActions< CounterActions, CounterState, CounterMutations, CounterGetters > = { incAsync ({ commit }, { amount, delay }) { setTimeout(() => { commit('inc', { amount }) // payload の型チェックもしてもらえる }, payload.delay) } }
Vue コンポーネントからの Vuex ストア利用については、下記のようにストアで定義した payload の型を import して利用するようにしています。
<script lang="ts"> import Vue from 'vue' import { CounterActions } from '~/types/store/counter' // ... export default Vue.extend({ // ... methods: { incAsync() { const payload: CounterActions['incAsync'] = { amount: 1, delay: 1000 } this.$store.dispatch('counter/incAsync', payload) } } }) </script>
これまでにぶつかった問題として、アクションやミューテーションに payload がない場合にも dispatch('fooAction', undefined)
というように undefined の payload を渡さなければ型エラーになってしまうというものがありました。
これについては問題を修正するプルリクエストを作成して、現在レビュー待ちです。
追記)マージしていただいて、vuex-type-helper@1.3.0
をリリースしていただきました!
まとめ
過去の記事(2020/09/14) にあるように、新規にデータストアが必要になった箇所については Vue.observable を利用するなど、Vuex に依存しない状態管理の仕方を模索しつつも、既存の Vuex ストアに関するコードについては、これまでに紹介してきた方法で型付けを進めていっています。
とはいえ、現在行っている対応は Vue 3 / Vuex 4 / Nuxt 3 への移行前の暫定的なものと認識しており、移行時にはまた何らかの対応が必要になるでしょう。
そうして、またひと山を超えて、より型安全なコードベースと、より良い開発体験を手に入れられたらと考えています。
この記事は hey アドベントカレンダー 2020 の 2日目の記事でした。明日は SRE チームの @akimoto による、STORES のインフラ構成の記事です!お楽しみに!