はじめに
せっかくTypeScriptを使っているのだから、Vue.setも型安全にしたい!と、誰しも一度は考えたことがあるのではないでしょうか?
この記事では、過程とともにVue.setを型安全にする実装をご紹介します。
TL;DR
最終的なコード
Vue.typedSet = (object: any, key: any, value: any) => Vue.set(object, key, value);
// 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の型
Vue.setの型はvue/types/vue.d.tsに以下のように定義されています。
set<T>(object: object, key: string | number, value: T): T;
set<T>(array: T[], key: number, value: T): T;
配列の場合は問題ないのですが、オブジェクトの値は本来の型とは異なるものを設定できてしまいます。
これは良くない、ということで型安全にする手立てを考えてみましょう。
型安全なVue.setの型を考える
まずは、型安全なVue.setがどのようなものかを考えてみましょう。
第2引数は第1引数で取りうるKeyを指定でき、第3引数では第2引数で指定した第1引数のKeyの値と同じ型を指定することができる、ということになります。
これを型定義に書き起こすと、以下のようになります。
set<T extends object, K extends keyof T>(object: T, key: K, value: T[K]): T[K];
これ1つで配列もオブジェクトもOKなSetメソッドの型を定義することができました。
VueConstructorへの型指定
Vue.setの型が定義されているvue/types/vue.d.tsのVueConstructorを拡張しましょう。
TypeScriptでは対象範囲全ての@types以下の型定義ファイルを自動でコンパイルに含めてくれます。
今回はsrc/@types/以下にvue.d.tsという型定義ファイルを作成しました。
この中に、VueConstructorを拡張するような型定義を書いてみましょう。
// 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をタイプセーフに拡張できました。試してみましょう。
キーは補完されるようになりました!続いて不正な値を設定してみましょう!
おやおや・・・obj.fooは文字列なのに0を設定できてしまいました。これはいけない。
不正な型を設定できる理由
TypeScriptのインターフェース拡張は「すでにある定義を壊さない形での拡張」のみが許されます。
さらに、関数の場合は引数の数が同じ定義の関数があればチェック対象となります。
つまりこの事象は、型安全な方の型チェックに引っかかったけど、後続の型安全でない方の型チェックがパスしたからOK、みたいな挙動になってしまっているわけです。
ということで、純粋にVue.setを拡張するだけでは元の定義が後に控えているためあまり意味がありません。
後続の型定義を参照させないようにする
TypeScriptで後続の型定義を探す条件は「全く同じ定義の型」に限られます。
以下のような型定義の場合はfn()は引数さえあれば型エラーになりません。
interface Sample {
fn(foo: string) => void;
fn(bar: any) => void;
}
fn('a');
fn({});
これに第2引数のあるメソッドを追加してみると、正しく型チェックが行われるようになります。当然といえば当然ですね。
interface Sample {
fn(baz: string, qux: number) => void;
fn(foo: string) => void;
fn(bar: any) => void;
}
fn('a', 0); // OK
fn(0, 0); // Error
つまり、引数の数を増やせば、後続の型定義を参照させずに済むということです。
これを悪用利用して、型定義上だけ、フラグを追加してみます。
// 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を利用することができるようになります。
先程の例でも文字列以外は設定できなくなりました。
で、終わってしまいたかったのですが、いつか本当にVue.setの第4引数にフラグが追加されてしまったときにこの型定義では困ります。
また、まやかしの型定義はそれ自体が黒魔術なのであまり胸を張って使えるものではありませんね。
代案を考えましょう。
Vue.typedSetを実装する
Vue.setの拡張は厳しそうです。
ならばいっそ、Vue.typedSetという新しいグローバルメソッドを定義してしまいましょう。
Vueのグローバルメソッドの追加は簡単で、Vue.newGlobalMethod = FunctionのようにVueにプロパティを生やすだけです。
Vue.typedSetはVue.setのエイリアスで良いので、実装自体は以下のようになります。
Vue.typedSet = (object: any, key: any, value: any) => Vue.set(object, key, value);
もし、Nuxtを使用しているのなら、プラグインを定義してこれをサーバー・クライアント双方で呼び出すようにしておけば問題ないでしょう。
// @/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に変更しましょう。
// 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できるようになりました!
めでたしめでたし。



