Edited at

Vuex型推論・最終章 - vuex-guardian -

Vue.js に TypeScript を導入する障壁の一つに、状態管理の定番である Vuex が TypeScript と相性が良くないという課題があります。状態管理はアプリケーションの中枢(モデル)とも呼べ、型システムによる保守担保が求められます。この課題に対し、これまでコミュニティから様々なアプローチが試みられました。

Vue.js 界隈における TypeScript の型推論といえば、クラスベースによるものが一般的です。先行きの不透明なデコレーターだけでなく、関数型の流行によるクラスベースへの不安感は、いっそうユーザーを困惑させて来ました。いま Vue.js 界隈は、型推論と標準準拠の板挟みに葛藤していると言っても過言ではないでしょう。

私はこれまで TypeScript 芸人としてこの Vuex 型課題に取り組み続け、書籍執筆などで提案を行なって来ました。その過程で得た知見から、標準書式・強推論・最小工数を実現するツールを開発したので、ここにご紹介します。この提案で開発者各位に求めるコードベースは次のようなものです。


store/counter/index.ts

import { LocalContext, LocalGetters, RootState, RootGetters } from 'vuex'

// ___________________________________
//
type Context = LocalContext['counter'] // Module 毎に要指定
type Getters = LocalGetters['counter'] // Module 毎に要指定
type State = { count: number }
// ___________________________________
//
export const state = (): State => ({ count: 0 })
// ___________________________________
//
export const getters = {
double(
state: State,
getters: Getters,
rootState: RootState,
rootGetters: RootGetters
) {
return state.count * 2
},
expo(state: State) {
return (amount: number) => state.count ** amount
}
}
// ___________________________________
//
export const mutations = {
increment(state: State) {
state.count++
},
decrement(state: State) {
state.count--
},
setCount(state: State, payload: { amount: number }) {
state.count = payload.amount
}
}
// ___________________________________
//
export const actions = {
acyncIncrement(ctx: Context) {
ctx.commit('increment')
},
acyncDecrement(ctx: Context) {
ctx.commit('decrement')
},
acyncSetCount(ctx: Context, payload: { amount: number }) {
ctx.commit({ type: 'setCount', amount: payload.amount })
}
}


書くべき型は、LocalGetters・LocalContext の特定と、各関数引数アノテーションのみであり、TypeScript 初学者であっても「なんとなく」で理解出来る、誰もが求めていた自然なコードベースです。これだけで、RootGetters や RootState、commit や dispatch の型安全までも、全て賄う事ができます。(SFC含む)

挙動を確認するため、まずはサンプルリポジトリで確認してみてください。README に記載しているとおり、Nuxt.js 本体の起動のほか、yarn codegenを別プロセスで起動する必要があります。

sample : https://github.com/takefumi-yoshii/vuex-guardian-example


vuex-guardian

LocalGetters・LocalContext など"vuex"から import している見慣れない型は、掲題の npm パッケージ「vuex-guardian」により自動生成された型です。開発時、この Node.js製のCLIツールをバックグラウンドで起動しておけば、推論に必要な煩雑な型定義は自動出力されます。

例えば、getter関数の実装を次の様に変更すると、戻り型推論は number から number | null に移り変わります。実装内容により導かれる型推論は、TypeScript 本来の素直なコードです。


store/todos/index.ts

// (method) doneTodosCount(state: State): number

doneTodosCount(state: State) {
// if (Math.random() > 0.5) return null
return state.todos.filter(todo => todo.done).length
}


store/todos/index.ts

// (method) doneTodosCount(state: State): number | null

doneTodosCount(state: State) {
if (Math.random() > 0.5) return null
return state.todos.filter(todo => todo.done).length
}

さて、このコメントアウトを外したことにより、どの様なエラーを得ることが出来ましたか? 私の VSCode 環境では、とある SFC がキチンと反応しています。


Vue.extend で何故型が効く?

SFCでも推論を効かせるための設定として、StrictStore 型を $store にアノテートする必要があります。この StrictStore 型もまた、自動生成された型です。

下準備として、各々 fork した Vuex を利用する必要があります。なぜなら、本家に付与された $store: Store<any> がブロッカーとなっているためです。diff を確認して分かる通り、変更箇所は削除のみであり、この手順を踏まなければ、SFC で恩恵を受けることが出来ません。

そもそも本来のStore 型への Generics 注入さえも Store<any> でブロックしているのですから、本家が間違っていることは明白です。不本意なパッチですが、今後はこのブロッカーが削除される様、コミュニティに働きかけていきたいと思っています。(普通に breaking change なので)


勘所は「参照型」の生成

TypeScript で型推論を導くためには、コンパイラが理解出来る参照を繋がなければいけません。Vuex はランタイムおいて、ライブラリから自動で各メンバーへの参照が挿入されます。このライブラリ特有の参照関係を、通常の推論で紐解くことは不可能です。

vuex-guardian は「参照を繋ぐ参照型を生成する」事に徹しています。生成された参照型は「関数名・変数名」など、開発者しか知り得ないプロジェクト固有の知識が詰まっています。これをライブラリのAPI型定義に通達することで、定義に則った型推論を導出することが出来る、という仕組みです。

次の型定義は、自動生成された参照型です。この自動生成ファイル一式は .gitignore に含めるものであり、普段気にかける必要はありません。


types/vuex/counter/index.ts

import * as Module from '~/vuex-guardian-example/store/counter/index'

import 'vuex'
declare module 'vuex' {
interface Modules {
counter: typeof Module
}
interface LocalState {
counter: ReturnType<Modules['counter']['state']>
}
interface LocalGetters {
counter: {
double: ReturnType<Modules['counter']['getters']['double']>
expo: ReturnType<Modules['counter']['getters']['expo']>
}
}
interface LocalMutationTypes {
counter: {
increment: A2<Modules['counter']['mutations']['increment']>
decrement: A2<Modules['counter']['mutations']['decrement']>
setCount: A2<Modules['counter']['mutations']['setCount']>
}
}
interface LocalActionTypes {
counter: {
acyncIncrement: A2<Modules['counter']['actions']['acyncIncrement']>
acyncDecrement: A2<Modules['counter']['actions']['acyncDecrement']>
acyncSetCount: A2<Modules['counter']['actions']['acyncSetCount']>
}
}
interface RootGetters {
'counter/double': LocalGetters['counter']['double']
'counter/expo': LocalGetters['counter']['expo']
}
interface MutationTypes {
'counter/increment': LocalMutationTypes['counter']['increment']
'counter/decrement': LocalMutationTypes['counter']['decrement']
'counter/setCount': LocalMutationTypes['counter']['setCount']
}
interface ActionTypes {
'counter/acyncIncrement': LocalActionTypes['counter']['acyncIncrement']
'counter/acyncDecrement': LocalActionTypes['counter']['acyncDecrement']
'counter/acyncSetCount': LocalActionTypes['counter']['acyncSetCount']
}
interface LocalContext {
counter: StrictContext<
LocalState['counter'],
LocalGetters['counter'],
LocalMutationTypes['counter'],
LocalActionTypes['counter']
>
}
}

このツールの実装には、TypeScript Compiler API を利用しており、AST解析から定義されている src の関数名を取得しています。自動生成による型定義のほか、Declaration Merging による interface の積層と、Conditional Types による導出で、参照を繋ぐことが出来ました。


使い方・諸注意事項


【想定開発環境】

このツールは、Nuxt.js の Vuex ストアモジュールモードにおける利用を想定しています(モジュールファイル現状未対応)。モジュールモードにおけるストア構築は、ファイルシステムに則り、ディレクトリ構造から名前空間解決が行われます。この定格所作が「参照型」の生成と合致したわけです。通常の Vue.js アプリケーションであっても、モジュールモードのルールに則ることで利用出来るはずです。(まだ試していません)


【tsconfig.json】

include スコープに生成型出力ディレクトリを指定する必要があります。


【監視先・出力先の指定】

デフォルトでは「./store」を監視・「./types/vuex」に出力される様に指定されています。リポジトリルートに「vuex-guardian.config.js」を設置すれば、任意の指定を適用できます。


vuex-guardian.config.js

module.exports = {

storeDir: "./store",
distDir: "./types/vuex"
}


【CIビルド時】

生成型は ignore されているため、nuxt generate の前に次の npm script が走る様にしなければいけません。-b または --buildオプションを付与することで、監視せずに、一度だけ生成されます。


package.json

"scripts": {

...,
"build:codegen": "vuex-guardian -b"
}


【出力のタイミング】

Store ディレクトリに存在する「index.ts」ファイルが保存される際に出力が走ります。「推論が効いてない?」と感じたら、編集したファイルを保存するか、参照型をエディタで開いてみてください。エディタと同じ速度で推論を効かせるためには、エディタ各種が発するイベントをツール側で subscribe する必要があります。これは、今後のブラッシュアップで搭載していきたいと思っています。


アプローチの違い

このツールは拙書「実践TypeScript」で紹介している解決方法の延長にあります。この書籍のサンプルコードでもあり、要約でもある次のリポジトリがベースとなっています。

https://github.com/takefumi-yoshii/ts-nuxtjs-express

こちらは「実装が型定義に従う」アプローチということができます。厳格な推論を得られてはいるものの、概念的な手動型定義を要求したり、工数がかさむなど「TypeScript で DX が向上する」と言い切れるものではありませんでした。

「vuex-guardian」は「実装から型推論をする」アプローチです。「素早く開発が行える・誰にも分かり易い」という観点は、Vue.js における大事なアイデンティティだと私は思います。このアイデンティティと、型課題解決を両立できた当アプローチは、今後の Vue.ts 全般にひとつの選択肢を持ち込めたのではないかと考えています。

npm publish したてなので、まだ不具合いが多いはずですが、引き続きブラッシュアップしていきたいと思っています。