Vuexは型がゆるいゆるいという話を散々されているのを見てきて、工夫すれば型の強化はできるんじゃないかと挑戦してみたところ、そこそこいい感じのものができたのですが、その過程でさまざまな工夫が必要だったためその知見を共有します。

実際に作ったPR

こちらのPRにてOpen中。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
const store = new Vuex.Store({
  state: { value: 0 },
  getters: {
    rootValue: (state) => state.value,
  },
  actions: {
    foo() {},
  },
  mutations: {
    foo() {},
  },
  modules: {
    a: {
      namespaced: true,
      state: { value: 1 },
      actions: {
        test: {
          root: true,
          handler({ dispatch }) {
            dispatch("foo");
          },
        },
        test2: {
          handler({ dispatch }) {
            dispatch("foo");
          },
        },
      },
      modules: {
        b: {
          state: { value: 2 },
        },
        c: {
          namespaced: true,
          state: { value: 3 },
          getters: {
            constant: () => 10,
            count(state, getters, rootState, rootGetters) {
              getters.constant;
              rootGetters.rootValue;
            },
          },
          actions: {
            test({ dispatch, commit, getters, rootGetters }) {
              getters.constant;
              rootGetters.rootValue;

              dispatch("foo");
              dispatch("foo", null, { root: true });

              commit("foo");
              commit("foo", null, { root: true });
            },
            foo() {},
          },
          mutations: {
            foo(state, payload: string) {},
          },
        },
      },
    },
  },
});

というのがあったときに、

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
store.dispatch("a/c/foo")
store.dispatch("a/c/test")
store.dispatch("a/test")
store.dispatch("a/test2")
store.dispatch("foo")

store.commit("a/c/foo","gg")
store.commit("foo")

store.state.a.b.value
store.state.a.c.value
store.state.a.value
store.state.value

store.getters["a/c/constant"]
store.getters["a/c/count"]
store.getters.rootValue

この辺がすべて型安全に書ける感じになっています。 また、dispatchやcommitの引数は補完が効くようになっており、第2引数の型は第1引数で指定した文字列によって決まるようになっています。

TypeScriptの柔軟性高い型表現

単なるジェネリクスだけではなく、 Mapped TypesConditional TypesTemplate Literal Types などにより、型を非常に柔軟に書けるのがTypeScriptの強みだと思っています。 Vuexは同一オブジェクトのなかで循環する形で参照する挙動(actions→commit()でmutationを呼ぶなど)があるので、こればっかりはTSで表現できないですが、少なくともVuex外とのインターフェースにおいては型安全に書けるのではないかと思いチャレンジしてみました。

今回使ったTIPSを難易度低い順に紹介しようと思います。(公式ドキュメントに記載されている内容は前提知識として割愛)

1. Index Types + Conditional Typesによる型表現の抽出

Vuexはmoduleという機能によりネストしたステート管理を実現しています。これがVuexの型表現を難しくしているものであり、いままで型を厳密に表現できてこれなかったものなのかなと思っています。

moduleは他のstateやactionsと同階層のとこに記述されるため、ネスト表現を取得するにはindexがmodulesのものを抽出し、かつmodulesの下にあるオブジェクトそれぞれに対して再帰的に型表現を呼び出していく必要があります。

store.stateの型表現を例に説明します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
type YieldState<S, T extends ModuleTree<S> | undefined> = (S extends () => any
  ? ReturnType<S>
  : S) &
  (T extends ModuleTree<S>
    ? {
        [K in keyof T]: T extends ModuleTree<S> // -1
          ? YieldState<
              T[K]["state"],
              T[K] extends { modules: object } ? T[K]["modules"] : {} // -2
            >
          : never;
      }
    : {});
...
export declare class Store<...> {
  readonly state: YieldState<S, SO["modules"]>;
}

ModuleTreeというのはmodules下のオブジェクトツリーを表している型なのですが、 1 で各moduleのオブジェクトを抽出した上で、 2 で更に下層にmodulesがあればそれを、なければ空を指定して再帰的に呼び出すことによってstateのツリー向上を実現しています。

2. 不活性仮型引数

今までのVuexの型は型引数にstateの型だけを渡してました。当然それだけではstate以外の型を解釈する事はできないため、型表現に限界があったわけです。 今回はStoreOptions(Vuex.extends()の引数で渡すオブジェクト全体)を型として指定できるようにすることによって、呼び出し時点での型で全体の方を表現できるようにしました。 一方後方互換性を考えたときに、第一型引数をstateからStoreOptionsに変えてしまうことは避けるべきです。ここで発生するのは

  1. stateの型だけを指定したVuexでも正常に動く
  2. StoreOptionsの型を指定した場合は従来のVuexも動くし、今回の型強化も使える

ということを実現する必要が出てきました。

今回こちらを解決する方法として第一型引数を不活性にし、第三型引数にて、stateの型をStoreOptionsの型を考慮した上で決定するという形にしました。

1
2
3
4
5
export declare class Store<
  _,
  SO extends StoreOptions<any> = StoreOptions<_>,
  S = SO["state"] extends _ ? SO["state"] : _
> {...

こうすることでStoreOptionsがあればそのstateを、なければ今までのstateの型で表現されるようになります。

3. Vuex特有のネスト構造の文字列結合によるフラット化

dispatch(), commit()は第一引数にネストしたモジュールを / つなぎで指定し、第2引数にその対象関数のpayloadを指定するという仕様になっています。 getterも関数ではないものの、同じようなネスト構造の表現をしています。

つまり

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
const s = {
  modules: {
    foo: {
      modules: {
        hoge: {
          actions: {
            bar: (n: number) => "aaa",
          },
        },
      },
    },
    bar: {
      actions: {
        bal: (x: string) => "aaa",
      },
    },
  },
};

1
2
3
4
{
  "bar/bal": (x: string) => string,
  "foo/hoge/bar": (n: number) => string
}

のような形に変換する必要があるわけです。今回はこれを下記のような形で実現しました

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
  k: infer I
) => void
  ? I
  : never;

type ExtractObject<
  T,
  S extends string = "actions",
  P extends string = ""
> = (T extends {
  [_ in S]: infer O;
}
  ? { [K in keyof O as `${P}${string & K}`]: O[K] }
  : {}) &
  (T extends {
    modules: infer O;
  }
    ? UnionToIntersection<
        {
          [K in keyof O]: ExtractObject<O[K], S, `${P}${string & K}/`>;
        }[keyof O]
      >
    : {});

内包されているTIPSは3つです。順番に説明していきます

UnionからIntersectionに変換する

まずひとつ目のUnionToIntersectionですが、こちらを参考にさせていただきました。 記載にある通りUnionからIntersectionに変換する型です。これは何が嬉しいかというと、Mapped Types を使うと、返却される型がUnion型になるのですが、今回導出したい型はあくまで一つのオブジェクトの型になるためIntersectionの型にする必要があり、ここで使用しています。

KeyRemappingを使った、コードジャンプの実現と/区切りのkey

KeyRemappingはTS4.1から導入された機能で、Mapped Types内で as を使うと新しいkeyを使うことができるものになります

1
2
3
type MappedTypeWithNewProperties<Type> = {
    [Properties in keyof Type as NewKeyType]: Type[Properties]
}

ExtractObject型の第3引数は今までの再起表現で渡されてきたprefixの文字列(moduleの / つなぎされた文字)になるのですが、そのprefixとkeyを結合することによってVuexの引数で使われている文字列にできます。 また、もともとのKeyをRemapしているだけなので、そのKeyをベースにコードジャンプができるようになり、

このような挙動を可能にしています。

Mapped Types と オブジェクトのあとの[keyof K]

今回型を実装するに当たり、他のTSプロジェクトの型実装なども参考にしていたのですが、よく出てくる表現で下記のようなものがありました

1
2
3
{
  [K in keyof T]: Hoge<T[K]>
}[keyof T]

これは何をしているかというと T 型の各keyに対する要素をHogeの型引数に指定し、それをすべてUnion型で結合したものとして返却します。 この表現によりオブジェクトをフラットに展開することができ、上記で説明したUnionToIntersectにより一つのオブジェクト型に戻すことができます。 Hogeに当たるところを今回は再起表現にしたため、ネストしている全ての抽出対象の関数をフラットに展開することができました。

まとめ

今回Vuexの型表現を強化するためにさまざまなTIPSを駆使して実現してきました。型パズルの欲求を満たすには丁度いいお題だったかなと思います。 まだこのPRではmapXxx()系やnamespacedがfalseのとき、root指定のmoduleのときなどが考慮されていないため、型表現としては不十分なところはあるのですが、ここは時間あるときに必要あればやっていこうかなと思います。