TypeScriptでVue.setを型安全にしたい

# はじめに

せっかくTypeScriptを使っているのだから、`Vue.set`も型安全にしたい！と、誰しも一度は考えたことがあるのではないでしょうか？

この記事では、過程とともに`Vue.set`を型安全にする実装をご紹介します。

# TL;DR

<details><summary>最終的なコード</summary>
<p>

```ts
Vue.typedSet = (object: any, key: any, value: any) => Vue.set(object, key, value);
```

```ts
// src/@types/vue.d.ts
import Vue from 'vue';

declare module 'vue/types/vue' {
  interface VueConstructor {
    typedSet<T extends object, K extends keyof T>(object: T, key: K, value: T[K]): T[K];
  }
}
```

</p>
</details>


# `Vue.set`の型

`Vue.set`の型は`vue/types/vue.d.ts`に以下のように定義されています。

```ts
  set<T>(object: object, key: string | number, value: T): T;
  set<T>(array: T[], key: number, value: T): T;
```

配列の場合は問題ないのですが、オブジェクトの値は本来の型とは異なるものを設定できてしまいます。

これは良くない、ということで型安全にする手立てを考えてみましょう。

# 型安全な`Vue.set`の型を考える

まずは、型安全な`Vue.set`がどのようなものかを考えてみましょう。

第２引数は第１引数で取りうるKeyを指定でき、第３引数では第2引数で指定した第１引数のKeyの値と同じ型を指定することができる、ということになります。

これを型定義に書き起こすと、以下のようになります。

```ts
  set<T extends object, K extends keyof T>(object: T, key: K, value: T[K]): T[K];
```

これ１つで配列もオブジェクトもOKなSetメソッドの型を定義することができました。

# `VueConstructor`への型指定

`Vue.set`の型が定義されている`vue/types/vue.d.ts`の`VueConstructor`を拡張しましょう。

TypeScriptでは対象範囲全ての`@types`以下の型定義ファイルを自動でコンパイルに含めてくれます。
今回は`src/@types/`以下に`vue.d.ts`という型定義ファイルを作成しました。

この中に、`VueConstructor`を拡張するような型定義を書いてみましょう。

```ts
// src/@types/vue.d.ts
import Vue from 'vue';

declare module 'vue/types/vue' {
  interface VueConstructor {
    set<T extends object, K extends keyof T>(object: T, key: K, value: T[K]): T[K];
  }
}
```

`declare module`は続くモジュール名（今回の場合は`node_modules/vue`以下の`types/vue.d.ts`を指定）の内部を拡張することができます。
`declare module 'vue/types/vue' { ... }`の`{ ... }`内は`vue/types/vue.d.ts`で`export`されているインターフェースをオーバーライドすることができます。

# 使ってみた

これで、`Vue.set`をタイプセーフに拡張できました。試してみましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/127934/a1094d7b-132c-344f-839e-65d5086534ee.png)

キーは補完されるようになりました！続いて不正な値を設定してみましょう！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/127934/fc37302d-338d-03b7-fc2f-62dd699e3f72.png)

おやおや・・・`obj.foo`は文字列なのに`0`を設定できてしまいました。これはいけない。

## 不正な型を設定できる理由

TypeScriptのインターフェース拡張は「すでにある定義を壊さない形での拡張」のみが許されます。
さらに、関数の場合は引数の数が同じ定義の関数があればチェック対象となります。

つまりこの事象は、型安全な方の型チェックに引っかかったけど、後続の型安全でない方の型チェックがパスしたからOK、みたいな挙動になってしまっているわけです。

ということで、純粋に`Vue.set`を拡張するだけでは元の定義が後に控えているためあまり意味がありません。

# 後続の型定義を参照させないようにする

TypeScriptで後続の型定義を探す条件は「全く同じ定義の型」に限られます。

以下のような型定義の場合は`fn()`は引数さえあれば型エラーになりません。

```ts
interface Sample {
  fn(foo: string) => void;
  fn(bar: any) => void;
}

fn('a');
fn({});
```

これに第２引数のあるメソッドを追加してみると、正しく型チェックが行われるようになります。当然といえば当然ですね。

```ts
interface Sample {
  fn(baz: string, qux: number) => void;
  fn(foo: string) => void;
  fn(bar: any) => void;
}

fn('a', 0); // OK
fn(0, 0);   // Error
```

つまり、引数の数を増やせば、後続の型定義を参照させずに済むということです。
これを~~悪用~~利用して、型定義上だけ、フラグを追加してみます。

```ts
// src/@types/vue.d.ts
import Vue from 'vue';

declare module 'vue/types/vue' {
  interface VueConstructor {
    set<T extends object, K extends keyof T>(object: T, key: K, value: T[K], isTyped: true): T[K];
  }
}
```

これで、第4引数に`true`が渡された場合は型安全な`Vue.set`を利用することができるようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/127934/4c93f1fb-b024-3bb5-6237-15e9dea2175c.png)

先程の例でも文字列以外は設定できなくなりました。

---

で、終わってしまいたかったのですが、いつか本当に`Vue.set`の第4引数にフラグが追加されてしまったときにこの型定義では困ります。
また、まやかしの型定義はそれ自体が黒魔術なのであまり胸を張って使えるものではありませんね。

代案を考えましょう。

# `Vue.typedSet`を実装する

`Vue.set`の拡張は厳しそうです。
ならばいっそ、`Vue.typedSet`という新しいグローバルメソッドを定義してしまいましょう。

Vueのグローバルメソッドの追加は簡単で、`Vue.newGlobalMethod = Function`のようにVueにプロパティを生やすだけです。

`Vue.typedSet`は`Vue.set`のエイリアスで良いので、実装自体は以下のようになります。

```ts
Vue.typedSet = (object: any, key: any, value: any) => Vue.set(object, key, value);
```

もし、Nuxtを使用しているのなら、プラグインを定義してこれをサーバー・クライアント双方で呼び出すようにしておけば問題ないでしょう。

```ts
// @/plugins/vue-typedSet.ts`
import { Plugin } from '@nuxt/types';

const plugin: Plugin = (context, inject) => {
  Vue.typedSet = (object: any, key: any, value: any) => Vue.set(object, key, value);
});

export default plugin;
```

最後に、先程拡張した型定義のメソッド名を`typedSet`に変更しましょう。

```ts
// src/@types/vue.d.ts
import Vue from 'vue';

declare module 'vue/types/vue' {
  interface VueConstructor {
    typedSet<T extends object, K extends keyof T>(object: T, key: K, value: T[K]): T[K];
  }
}
```

新しいメソッドを定義してしまいましたが、これで型安全に`Vue.set`できるようになりました！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/127934/ee193bab-eb63-b419-321e-03a5acf036a3.png)

めでたしめでたし。

# 参考

* [Vue の拡張 - vue.js](https://012-jp.vuejs.org/guide/extending.html)
* [foo, bar, baz の続き - Qiita](https://qiita.com/ka_/items/3727c3f16dc69fed935c)