はじめに
せっかく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
できるようになりました!
めでたしめでたし。