7
5

More than 3 years have passed since last update.

Vuex に型情報を付加する話と今後の野望(だったもの)

Posted at

本記事は Vue Advent Calendar 2019 18 日目の記事です。

Vuex に型情報を付加する話と今後の野望だったものと題して、 Vue + Vuex with TypeScript の開発体験を向上するために試行錯誤したことについて話していきます。

はじめに

Vuex モジュールに型情報を付けたい、そう思ったのは Vue を TypeScript で書くみなさんなら誰しも1度は考えたことがあるのではないかと思います。

今年の春、ちょうど型パズルを使えるようになってきた時期だった私は去来したその考えをすぐ実行に移すこととしました。

1. 後付の型情報

最初は、モジュールの型情報を type キーワードや interface キーワードを使って定義したあと、mapState などのヘルパの返り値を as キーワードでキャストして型情報を付けていきました。

module.ts
export type State = {
  value: string;
}

export default {
  state: {
    value: 'Hello, Vuex!'
  }
}
Component.vue
<template>...<template>

<script lang="ts">
import { mapState } from 'vuex';
import { State } from '@/stores/modules/module.ts';

const mappedState = mapState('module', ['value']) as State;

export default Vue.extend({
  computed: {
    ...mappedState
  }
})
</script>

これで Vuex 由来のプロパティに型情報を乗せることができるようになりました。

しかし、この方法では以下の問題がありました。

  • 手動での型付けのため、モジュールの実態と型情報がリンクしていない
    • どこまで正確に型をつけるかは実装者の良心によるところが大きい
    • 実装と型定義の二重実装になり、手間がかかる
  • mapXXXX ヘルパは部分的にモジュールを引っ張ってこれる
    • 型パズルを駆使して適宜型情報を制限しなくてはならない
    • こちらも二度手間

これらの問題を解決するために考えついたのは、「mapXXXXヘルパに実装から抽出した型情報を持たせる」ことでした。

2.実装から型を抽出する

TypeScript で実装から型を抽出する方法、と聞いてみなさんは何を思い浮かべたでしょうか。
Compiler API などの裏技を除けば、関数の引数を使ってジェネリクス型に拾ってもらうのが一般的ではないかと思います。
実際、Vue.js がとった方法も Vue.extends() を用いた方法でした。

const just = <T>(value: T) => value;

just('hogehoge'); // T = string
just(1); // T = 1
just(true); // T = boolean

このコードスニペットのように、ジェネリクス型Tを引数として受け取る関数に、
任意の型を渡した際に T はその型として推論が行われます。

これを利用して、ジェネリクス型に State, Actions, Mutations, Getters の型をそれぞれ抽出してしまえば第1段階クリアです。

import { ActionTree, Module, MutationTree, GetterTree } from "vuex";
type State<S> = S | () => S;

interface FullyTypedModuleDefinition<
  S,
  R extends any,
  A extends ActionTree<S, R> = {},
  M extends MutationTree<S> = {},
  G extends GetterTree<S, R> = {}
> extends Module<S, R> {
  state?: State<S>;
  actions?: A;
  mutations?: M;
  getters?: G;
}

export interface FullyTypedModule<
  S,
  R extends any,
  A extends ActionTree<S, R> = {},
  M extends MutationTree<S> = {},
  G extends GetterTree<S, R> = {}
> extends Module<S, R> {
  state: State<S>;
  actions: A;
  mutations: M;
  getters: G;
}

/**
 * Defines fully-typed Vuex module.
 *
 * @param mod definition of Vuex module
 */
export const buildModule = <
  S,
  R extends any,
  A extends ActionTree<S, R> = ActionTree<S, R>,
  M extends MutationTree<S> = MutationTree<S>,
  G extends GetterTree<S, R> = GetterTree<S, R>
>(
  mod: FullyTypedModuleDefinition<S, R, A, M, G>
): FullyTypedModule<S, R, A, M, G> => ({
  state: {} as State<S>,
  actions: {} as A,
  mutations: {} as M,
  getters: {} as G,
  ...mod
});

モジュール定義から型情報をしっかり持ったモジュール定義を出力しているだけの関数です。
オプショナルな項目に空の初期値を与えたくらいの変化しかありません。

buildModule({
  state: {
    value: 'Hello, Vuex!'
  }
}); // S = { value: string }

これで実装に沿った型情報を抽出することができました。
(例が長くなるので載せていませんが、他の項目もちゃんと抽出できます)

3. 型パズルの時間

さて、あとは抽出した型情報をもとに、型パズルこねこねして mapXXXX の返り値の型を作るだけです。

3-1. 抽出した型情報から mapXXXX の返り値の型を組み立てる

import { Dictionary, Computed, mapState } from "vuex";

type MappedState<S> = Dictionary<Computed> & { [P in keyof S]: () => S[P] };

まずは返り値の型がなきゃ始まらないので、返り値の型を作ります。
Mapped Types を使えばかなり楽に定義できます。

Actions などは関数なのでちょっとトリッキーなことをする必要がありますが、
Conditional Types を使えばどうにかなります。見栄えは悪いですが

3-2. mapXXXX の型定義を参考に関数の型定義

export interface StateMapper<State> {
  <Key extends keyof State>(map: Key[]): MappedState<Pick<State, Key>>;
  <Key extends keyof State>(namespace: string, map: Key[]): MappedState<
    Pick<State, Key>
  >;
  (): MappedState<State>;
  (namespace: string): MappedState<State>;
}

interface の関数型のオーバーロードを使って、Vuex 内の mapXXXX の型定義を参考に新しい mapXXXX のインタフェースを定義していきます。

Vuex の mapXXXX にはオブジェクトでのマッピングによる定義もありますが、簡単のためオミットしました。
また、全てのキーを取得する際はキーの指定が必要ないような定義を追加しています。

3-3. 実装

定義したシグネチャに従って、mapXXXX をラップする形で実装を進めていきます。
関数の引数を介さないとジェネリクス型の評価を使用時に行えないので、
捨てパラメータとして _state: S を定義しています。

import { Dictionary, Computed, mapState } from "vuex";
import { keyOf } from "../utils/keyof";

type MappedState<S> = Dictionary<Computed> & { [P in keyof S]: () => S[P] };

const stateMapper = <S, K extends keyof S>(_state: S, map: K[]) =>
  mapState(map as string[]) as MappedState<S>;
const stateMapperWithNamespace = <S, K extends keyof S>(
  _state: S,
  namespace: string,
  map: K[]
) => mapState(namespace, map as string[]) as MappedState<S>;

export interface StateMapper<State> {
  <Key extends keyof State>(map: Key[]): MappedState<Pick<State, Key>>;
  <Key extends keyof State>(namespace: string, map: Key[]): MappedState<
    Pick<State, Key>
  >;
  (): MappedState<State>;
  (namespace: string): MappedState<State>;
}

export const mapStateWithType = <S>(state: S): StateMapper<S> => <
  K extends keyof S
>(
  ...args: [K[]] | [string, K[]] | [string] | []
) => {
  if (!args.length) {
    return stateMapper(state, keyOf(state));
  }

  const isNamespaceOnly = (
    val: [K[]] | [string, K[]] | [string] | []
  ): val is [string] => val.length === 1 && typeof val[0] === "string";
  if (isNamespaceOnly(args)) {
    return stateMapperWithNamespace(state, args[0], keyOf(state));
  }

  const isWithNamespace = (val: [K[]] | [string, K[]]): val is [string, K[]] =>
    typeof val[0] === "string";
  if (isWithNamespace(args)) {
    const [namespace, map] = args;
    return stateMapperWithNamespace(state, namespace, map);
  }

  const [map] = args;
  return stateMapper(state, map);
};

あとは 2. で抽出した型情報のついたモジュールの項目を mapStateWithType に食わせれば型情報のついた mapXXXX ヘルパの出来上がりです。

作成したライブラリはこちらになります。

4. 今後の野望だったもの

このライブラリを作り終え、使っていくうちにある野望が浮かんできました。

モジュール定義のときも型情報を付けたい!!

今回のライブラリで提供したのは既に定義済みのモジュールから
マッピングヘルパを介してコンポーネントに型情報を提供する方法であり、
モジュール定義自体は Vuex 自身の甘い型定義に頼るほかありませんでした。

なので、モジュール定義自体も Vue.extends() が対応したようにしっかりと型定義できれば、
より良い開発体験が得られるに違いない!と考えたわけです。

その際に重視したのは、現行の Vuex API を踏襲して
そのまま Vuex で使用可能な定義モジュールを出力することでした。

そうすれば、公式に沿ったピュアなオブジェクトを介して、定義とマッピングを自作のライブラリで型情報をつけられる。
使って貰える人は両方セットではなくてどっちかほしい方だけでも使えるようになる、と考えてのことでした。

結果的に言えば、この試みは失敗でした。

4-1. 自己言及的な API の存在

その原因は自己言及的な Vuex の構造にあります。
例えば、ある Action は他の Actions を呼び出せます。
それは、Action の第1引数の中に Actions が含まれているからです。
つまり、Actions の定義の際には Actions 全体の情報が必要になる、「鶏が先か卵が先か」のジレンマが存在したのです。

Vue 自体の構造にも同じことが言えます。
算出プロパティは他の算出プロパティを呼び出せる、
すなわち算出プロパティの定義には他の算出プロパティが必要になるはずでした。

しかし、Vue 自体の API はそれらは全て this オブジェクトから利用するため、
Vue.extends() は定義から抽出した型情報を関数内の this オブジェクトの型として反映させる形で、鶏と卵のジレンマを回避することができたのです。

ですが、Vuex 定義はそうは行きません。
ここで、開発を諦めるか、API 構造を変更するかの選択を迫られました。

悩みましたが、API 構造を変更することを選びました。

  • Actions が Actions を利用するのではなく、 Orchestrator という項目を新規定義して、これが Actions の呼び出しをコントロールするように API 構造を修正
  • Actions, Getters の自己言及的な API を型定義から削除

この2点を実施することで、項目同士の依存関係は以下のようになりました。

vuex-typed-definer.png

これなら、再帰的な定義を必要とせずに型定義を作成できます。

しかし、なおも壁が立ちはだかります。

4-2. 型定義を抽出できない

前の依存グラフを見ると、例えば State は他の全ての項目から依存されています。
これは、ジェネリクス関数の引数定義に型変数が複数回登場するということを意味します。

ジェネリクス関数の引数定義で型情報を抽出するには、引数から型が一意に定まることが重要です。
しかし、引数定義に型変数が複数回登場すると、型が一意に定まらず、型変数の表す型が曖昧になってしまいます。

これを解決するためには、一度に全ての項目を定義することを諦めねばなりませんでした。
メソッドチェーンなどを利用して次の項目の定義を促すような API を作成する必要があったのです。

現状、この問題を解決するためのスマートな API インタフェースを考案している段階です。

おわりに

現状の Vuex API は TypeScript で利用するためにはあまりにも型情報が曖昧です。

それを解決するために色々と模索してきた上で思うのは、
「確かにこれを現状のまま解決するのにはコストが掛かりすぎる」ということです。

今、私が夢想しているのは、Vue Composition API によって Vuex の必要なくなった未来です。

Vuex がもたらしたのは、データハンドリングフローの一貫性と Vue.js Dev Tools によるアプリケーションの状態の透明化、タイムトラベル・デバッグでした。
Vue Composition API にこれらは不可能なことでしょうか?
私は決して不可能なことではないんじゃないかな、とかなり楽観的に考えています。。。

7
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
7
5