VueでWatch対象のパスを安全に指定できるライブラリ vuekey を作った

# 作ったもの

Vueで値を監視して変更があったらハンドラを発火させる`Watch`ですが、パスの指定が文字列なので存在しないパスを指定しても誰も怒ってくれません。

そこで、`vue-property-decorator`の`@Watch`デコレータ向けにデータへのパスを安全に指定できるライブラリ`vuekey`を作りました。

> [@lollipop-onl/vuekey](https://www.npmjs.com/package/@lollipop-onl/vuekey)

この記事では、簡単に使い方と内部実装についてご紹介します。

# 使い方

## インストール

まずは、[`@lollipop-onl/vuekey`](https://www.npmjs.com/package/@lollipop-onl/vuekey)をインストールしてください。

```sh
$ npm i --save @lollipop-onl/vuekey
# or 
$ yarn add @lollipop-onl/vuekey
```

## Vueファイル内でセットアップ

`vuekey`では`setupVuekey`という関数を提供しています。

この関数のGenericとしてVueクラスを渡すと、そのクラス内で有効なパラメータへのパスを検証することができます。

```ts
import { Component, Vue } from 'vue-property-decorator';
import { setupVuekey } from '@lollipop-onl/vuekey';

// vuekeyのセットアップ
const vuekey = setupVuekey<SampleComponent>();

@Component
export default class SampleComponent extends Vue {}
```

## `vuekey`でパスを指定

`vuekey(<key>, <key>, ...)`とパラメータのキーを指定していきます。最大10個ネストしたパラメータまでを検証できます。

```ts
import { Component, Watch, Vue } from 'vue-property-decorator';
import { setupVuekey } from '@lollipop-onl/vuekey';

// vuekeyのセットアップ
const vuekey = setupVuekey<SampleComponent>();

@Component
export default class SampleComponent extends Vue {
  profile: {
    name: string;
    age?: number;
    works: IWark[];
    updatedAt: number;
  };

  // プロフィールが変更されたらタイムスタンプを更新する
  @Watch(vuekey('profile', 'name'), { immediate: true })
  @Watch(vuekey('profile', 'age'))
  onChangeProfile() {
    this.$set(this.profile, 'updatedAt', Date.now());
  }
}
```

これで、存在しないパラメータキーを指定しようとしたらエラーとなります。

## `vm.$set`もタイプセーフにする

`this.$set`はオブジェクトや配列の要素をリアクティブに変更するためのビルトインメソッドですが、第2引数、第3引数は方に縛られていません。

ここも存在するキー、代入可能な型かどうかを検証させる機能を`vuekey`に持たせています。

先程のプロフィールのタイムスタンプを更新する例は以下のように変更できます。

```ts
import { Component, Watch, Vue } from 'vue-property-decorator';
import { setupVuekey } from '@lollipop-onl/vuekey';

// vuekeyのセットアップ
const vuekey = setupVuekey<SampleComponent>();

@Component
export default class SampleComponent extends Vue {
  profile: {
    name: string;
    age?: number;
    works: IWark[];
    updatedAt: number;
  };

  // プロフィールが変更されたらタイムスタンプを更新する
  @Watch(vuekey('profile', 'name'), { immediate: true })
  @Watch(vuekey('profile', 'age'))
  onChangeProfile() {
    this.$set(...vuekey(this.profile, 'updatedAt', Date.now()));
  }
}
```

これで、`this.profile`の型定義に存在しないプロパティの指定や、不正な型の代入を防ぐことができます。

Vueで文字列だからとおざなりになりがちなところですが、型安全にして平和にいきましょう！

# 実装のこと

ここからは、`vuekey`を作るにあたって困った点を備忘録として残します。
同じ轍を踏む人が一人でも減りますように...

> [ソースコード](https://gitlab.com/lollipop.onl/vuekey/blob/master/index.ts)

## 前の引数で指定したプロパティの持つキーのみを続く引数で指定させる

`vuekey`では、第1引数で指定したパラメータのキーを第2引数の型とするような機能が必要でした。

当初は

```ts
// vuekey<V extends Vue>は省略しています

function vuekey<V>(t1?: keyof V, t2?: keyof V[typeof t1], t3: keyof V[typeof t1][typeof t2], ...)
```

のように、前の引数の型を後半でも参照するようにしていました。

しかし、この実装では前半の型は確定しなかったため没になりました。

```ts
function vuekey<V>(t1?: keyof V, t2?: keyof V[typeof t1] // typeof t1が確定していないのでV[typeof t1]がneverになった
```

そこで、インターフェースのジェネリックで内部状態（？）として型を指定するようにしました。

```ts
interface Vuekey<V extends Vue> {
  <
    T1 extends keyof V,
    T2 extends keyof V[T1],
    T3 extends keyof V[T1][T2]
  >(t1?: T1, t2?: T2, t3?: T3): string;
}
```

こうすることで、`T2`での`T1`は実際に指定された定数値を参照することができました。

## Nullableなオブジェクトでの`keyof`

任意のクラスメンバーは` | undefined`となりますが、このような値に対して`keyof`すると`never`が返されてしまいます。

任意のパラメータを除去するには`Required`というビルトインの型を使用します。

さて、任意のパラメータがネストする場合はどうすればよいでしょうか？おそらく`DeepRequired`みたいな型を作れば良いと思います。

```ts
type DeepRequired<T> = T extends object
  ? { [K in keyof T]-?: DeepRequired<T[K]> }
  : T;
```

ただし、`vuekey`の場合は事情が違います。`vuekey`では、`T`にあたる型の親のパラメータが任意である可能性があり、`DeepRequired`は利用できませんでした。

そこですべての型を`Required`でラップするという手段で実装を行っています。

> [該当箇所](https://gitlab.com/lollipop.onl/vuekey/blob/master/index.ts#L28)

もっと良い方法があればコメントで教えていただきたいです！