TypeScript
vue.js
Vuex

TypeScriptでVuexを型安全に扱うライブラリを作りました

はじめに

こんにちは。
みなさんVue&Vuex&TypeScript やってますか?

Vueクラススタイルコンポーネント向けのライブラリのなかで
vuex-classというライブラリがすごく気に入ったのですが型安全ではありません。
↑ リポジトリを見るとIssueは立ってるようです

そこで今回、車輪の再発明を承知でライブラリを作りました!
vuex-class的な使い心地で型安全なものを目指します:point_up:

コード

まずは作ったものはこちら
vuex-map-decorators

基本的にデコレータを実装しているので、
実装内容より先に使い方を説明します。

使い方

シンプルな使い方

import { State, Mutation } from 'vuex-map-decorators';

interface SampleState {
  count: number;
}
interface HogeState {
  message: string;
}
interface SampleMutation {
  increment: () => void;
}

@Component
class Sample extends Vue {
  @State<SampleState>('count')
  count!: number;

  @Mutation<SampleMutation>('increment')
  onClick!: () => void;

  @State<HogeState>('message', 'sample') // 第二引数はnamespace (sample/message)
  message!: string;

  // SampleStateに存在しないキーはコンパイルエラーになる
  @State<SampleState>('message') // <- Error
  message: any;
}

解説

@StateをつけたプロパティへはVuexStoreのstate部分が割り当てられます。
例では@State@Mutationのみですが、
@Getter@Actionも用意しており使い勝手は全て同じです。

引数 マップされる先
@State<T>(key: keyof T, namespace?: string | undefined) computed
@Mutation<T>(key: keyof T, namespace?: string | undefined) methods
@Getter<T>(key: keyof T, namespace?: string | undefined) computed
@Actions<T>(key: keyof T, namespace?: string | undefined) methods

デコレータの第一引数は<T>(keyof T)となっているため、
型引数<T>の型に存在しないキーを渡せないようになっています。

デコレータをつけるプロパティの型については、
このままだと任意になってしまい微妙なので対応したいところですね。
内容がライブラリと少し離れてしまうため別の記事にしたいと思います。

Namespaceを意識した使い方

前述した内容では確かに型安全になりますが、あまりにも記述する内容が多すぎます。
そこでNamespaceを意識した方法を用意していますので紹介します。(むしろこっちがメイン

import { namespace } from 'vuex-map-decorators';

interface SampleState {
  count: number;
}
const state: SampleState = {
  count: 0,
};

const mutations = { ... };
const getters = { ... };
const actions = { ... };

export const ns = namespace<
  SampleState,
  typeof mutations,
  typeof getters,
  typeof action,
>('sample');

@Component
class Sample extends Vue {
  @ns.State('count');
  count!: number;
}

// ↓ これと同じ意味になります
@State<SampleState>('count', 'sample')

解説

namespace関数にstate, mutaions, getters, actionsの順で型を渡して、
引数には名前空間を文字列で渡します。

もし「gettersはないんだよね」って時は、型にneverを渡してあげてください

const ns = namespace<
  SampleState,
  typeof mutations,
  never,      // 実装していない部分はneverで
  typeof action,
>('sample');

ns.Getter // <- ここではエラーになる

型にneverを渡すことで、返却されるオブジェクトの型が変わり、
参照そのものができなくなります。

const ns1 = namespace<A, B, C, D>('sample');
/*
  ns1 = {
    State(k: keyof A){},
    Mutation(k: keyof B){},
    Getter(k: keyof C){},
    Action(k: keyof D){},
  }
*/

const ns2 = namespace<A, never, never, D>('sample');
/*
  ns2 = {
    State(k: keyof A){},
    Action(k: keyof D){},
  }
*/

従来のVuexコンテキストを利用する場合

デコレーションを使わずcommitdispatchを利用する場合です。
やはりどうしてもデコレーションだけだと辛いこともあるかもしれないので、
以下のような手段を用意しました

interface Mutations {
  increment: (state: State) => void;
}

const ns = namespace<State>('views/sample');
const key = ns.Mutation('increment').key; // views/sample/increment
$store.commit(key);

少し野暮ったいですが、文字リテラルでも型に守られるため
この手段を使うこともありかと思います。
ただし、Payloadの型までは現時点で推論できないため今後の課題です。

実装内容

実装は1ファイルです。
まずはデコレータを作成するところをみてみます

デコレータ作成部分

function createVuexDecorator(
  bindTo: 'computed' | 'methods',
  mapper: VuexMapper,
) {
  return <T = unknown>(key: keyof T, namesapce?: string | undefined): VuexDecorator => {
    if (typeof(key) !== 'string') {
      throw Error(`key is not string: key=${key}, ns=${namesapce}`);
    }
    const dec = createDecorator((target, prop) => {
      if (!target[bindTo]) {
        target[bindTo] = {};
      }
      target[bindTo]![prop] = (
        namesapce ? mapper(namesapce, [key]) : mapper([key])
      )[key];
    }) as VuexDecorator;
    dec.key = namesapce ? `${namesapce}/${key}` : key;
    return dec;
  };
}

export const State = createVuexDecorator('computed', mapState);
export const Mutation = createVuexDecorator('methods', mapMutations);
export const Getter = createVuexDecorator('computed', mapGetters);
export const Action = createVuexDecorator('methods', mapActions);

createVuexDecoratorを呼ぶことでそれぞれのデコレータを作成しています。

引数

type VuexMapper = (
  | typeof mapState
  | typeof mapMutations
  | typeof mapGetters
  | typeof mapActions
);

function createVuexDecorator(
  bindTo: 'computed' | 'methods',
  mapper: VuexMapper,
) 

どこに何でマッピングするかを定義します。
Stateの場合は「computedにmapState」でマッピングを行なっています。
VuexMapperはvuex標準のmapXXXXのどれかです。

デコレータの実装部分

import { createDecorator, VueDecorator } from 'vue-class-component';
interface VuexDecorator extends VueDecorator {
  key: string;
}
/*
  中略
*/
  return <T = unknown>(key: keyof T, namesapce?: string | undefined): VuexDecorator => {
    // 入力チェックは省略
    const dec = createDecorator((target, prop) => {
      if (!target[bindTo]) {
        target[bindTo] = {};
      }
      target[bindTo]![prop] = (
        namesapce ? mapper(namesapce, [key]) : mapper([key])
      )[key];
    }) as VuexDecorator;
    dec.key = namesapce ? `${namesapce}/${key}` : key;
    return dec;
  };

戻り値

Vue公式のvue-class-component内のVueDecoratorを拡張しており、
従来のVuexコンテキストを利用する場合内で説明したkeyを追加しています。

本体

まずは受け取った型から引数を絞ります。
ここではVue公式のcreateDecoratorを利用することで実装がとてもシンプルになります。

targetはコンポーネントで、propはデコレータがついていたプロパティになり、
target['computed' | 'methods']mapXXXXの結果を突っ込んでいく形で実装しています。

名前空間作成部分

この戻りの型定義が一番面白いところです!
そしてなくても困らない部分です...:smile:

export function namespace<
  S = never,
  M = never,
  G = never,
  A = never,
>(n: string): DecoratorInterface<S, M, G, A> {
  return {
    State: (k: keyof S) => State<S>(k, n),
    Mutation: (k: keyof M) => Mutation<M>(k, n),
    Getter: (k: keyof G) => Getter<G>(k, n),
    Action: (k: keyof A) => Action<A>(k, n),
  };
}

見ての通り、先ほど作成したデコレータにnamespaceを渡し、オブジェクトに固めているだけです。
この際にデコレータの引数で名前空間を受け取れなくしています。

ここで戻り値の型となっているDecoratorInterfaceの説明を行いましょう!

名前空間オブジェクトの型

型定義は以下のようになっています。

type IsNever<N> = [N] extends [never] ? 'T' : 'F';
export type IfNotNever<T, R> = {T: {}, F: R }[IsNever<T>];

type DecoratorMaker<T> = (k: keyof T) => VuexDecorator;
type DecoratorInterface<S, M, G, A> = (
  & IfNotNever<S, { State: DecoratorMaker<S> }>
  & IfNotNever<M, { Mutation: DecoratorMaker<M> }>
  & IfNotNever<G, { Getter: DecoratorMaker<G> }>
  & IfNotNever<A, { Action: DecoratorMaker<A> }>
);

はい出ました。TypeScript型遊びの時間です。

IsNever<N>

NがNeverかどうかを判定し,
NがNeverの場合は'T'、違う場合は'F'を返却します。
ちなみにここでいう'T'は値ではなく'T'という型です。

ここで[never]のように配列にしなかった場合、うまくいきませんでした。
ハマったので調べてみるとTypeScript本家のIssueに書いてあってのでなんとか解決。
https://github.com/Microsoft/TypeScript/issues/23182

IfNotNever<T, R>

先ほど定義したIsNeverを利用します。
もしTがneverだった場合は空オブジェクトを、違う場合はRを返却します。
こうすることで、neverじゃない場合のみ、プロパティが参照できるようになります。

あとがき

自分で作ったので当然ですが、
なかなか使い勝手がいい感じに仕上がりました。

実際に使い込んでみて都合が悪いことがあれば都度直していきたいと思います。

やっぱりTypeScriptの型でモニョモニョするのは楽しいですね:relaxed:

おしまい