この記事はVue #2 Advent Calendar 2019の11日目です。
昨日は @nishihara_zari さんの jQueryしか書けなかった自分がVueを使えるようになるまでの話と同じような環境の人たちへ でした。
はじめに
以前、TypeScriptでVue.setを型安全にしたいという記事を書ました。
今回はその記事で紹介した、Vue.set(とVue.delete)を型安全にする実装をライブラリ化したので、実装内容についてご紹介します。
本文だけ読みたいという方は 実装について まで読み飛ばしてください。
ライブラリのご紹介
実装の紹介の前に、公開したライブラリについてご紹介させていただきます。
@lollipop-onl/vue-typed-reactiveはVueのグローバルにVue.typedSetとVue.typedDeleteを、インスタンスにvm.$typedSetとvm.$typedDeleteを提供するプラグインです。
$ yarn add @lollipop-onl/vue-typed-reactive
# or
$ npm install -S @lollipop-onl/vue-typed-reactive
使い方
使い方は簡単で、Vueへの登録とtsconfig.jsonへの設定の追加のみです。
// Vueへの登録
import Vue from 'vue';
import TypedReactive from '@lollipop-onl/vue-typed-reactive';
Vue.use(TypedReactive);
Nuxtの場合
Nuxtの場合はプラグイン化して登録するのがおすすめです。
// @/plugins/libs/vue-typed-reactive.ts
import { Plugin } from '@nuxt/types';
import Vue from 'vue';
import TypedReactive from '@lollipop-onl/vue-typed-reactive';
const plugin: Plugin = () => {
Vue.use(TypedReactive);
};
export default plugin;
import { Configuration } from '@nuxt/types';
const config: Configuration = {
...
plugins: [
'@/plugins/libs/vue-typed-reactive',
],
...
};
tsconfig.jsonでは、typesオプションにこのライブラリを追加してください。
// tsconfig.json
{
"compilerOptions": {
"types": [
"@lollipop-onl/vue-typed-reactive"
]
}
}
あとは、Vueコンポーネント内であればthis.$typedSetを、VuexのMutationの中などであればVue.typedSetを使用するようにするだけです。
interface IUser {
name?: string;
age: number;
}
// Vue Component
@Component
export default class SampleComponent extends Vue {
profile: IUser = { age: 0 };
onChangeName(name: string): void {
this.$typedSet(this.profile, 'name', name);
}
clearProfileName(): void {
this.$typedDelete(this.profile, 'name');
this.$typedDelete(this.profile, 'age'); // TypeError!
}
}
// Vuex Store Module
export const mutations = {
setUserName(state: IState, name: string): void {
Vue.typedSet(state.user, 'name', name);
},
};
実装上は、Vue.typedSetはVue.setの完全なエイリアスなので、全て置き換えても正常に動作すると思います(deleteも同様)。
機能
typedSetとtypedDeleteいずれも、プロパティと値の型安全以外にも 余計な 機能をつけました。
typedSetでは、readonlyなプロパティへの値の設定ができないようにし、typedDeleteでは、Optionalなプロパティのキーのみ削除できるようにしました。
interface IFoo {
bar: number;
baz?: string;
readonly qux: string;
}
const foo: IFoo = { ... };
Vue.typedSet(foo, 'bar', 100); // ok.
Vue.typedSet(foo, 'qux', 'hello'); // Argument of type '"qux"' is not assignable to parameter of type 'never'.
Vue.typedDelete(foo, 'bar'); // Argument of type '"bar"' is not assignable to parameter of type 'never'.
Vue.typedDelete(foo, 'baz'); // ok.
Vue.typedDelete(foo, 'qux'); // Argument of type '"qux"' is not assignable to parameter of type 'never'.
また、もともとのVueの実装は残っているため、対応不可な型エラーはVue.set、Vue.deleteで回避することができます。
恩恵
実際に開発に組み込んでみて、VuexストアのMutationでとても活躍しています。
VuexのモジュールモードはモジュールがネストするとMutation内でのVue.setの使用が必須となります。
せっかくTypeScriptを使っているのにStateへの代入時の型不整合がチェックできないのはそこそこしんどいと思っていました。
また、プロパティ名の変更などもVue.set、Vue.deleteでは検知できないので手動リファクタが必要なってきます。
そういうのって面倒ですよね。
Vue.typedSetとVue.typedDeleteを使ってよりリファクタリングのしやすいVueアプリにしませんか?
実装について
ここからは、vue-typed-reactiveの実装内容についてご紹介します。
以降はほぼほぼTypeScriptに関する内容になります。
Vueのアドベントカレンダーなのにすみません。。
Vueの拡張
Vue.typedSet、Vue.typedDeleteというメソッドはVueには存在しません。
それぞれVue.set、Vue.deleteのエイリアスとして機能するよう、Vueを拡張してます。
// グローバルメソッド Vue.typedSet
Vue.typedSet = Vue.set;
Vue.typedDelete = Vue.delete;
// インスタンスメソッド vm.$typedSet
Vue.prototype.$typedSet = Vue.set;
Vue.prototype.$typedDelete = Vue.delete;
これらをVue.useでVueに登録できるようにするために、Vueプラグイン形式のオブジェクトでラップしています。
import { PluginObject } = 'vue';
const TypedReactive: PluginObject<never> = {
// Vue.useしたときに呼ばれるメソッド
install: (Vue) => {
// グローバルメソッド Vue.typedSet
Vue.typedSet = Vue.set;
Vue.typedDelete = Vue.delete;
// インスタンスメソッド vm.$typedSet
Vue.prototype.$typedSet = Vue.set;
Vue.prototype.$typedDelete = Vue.delete;
},
};
PluginObjectはVueプラグインの型情報です。Genericにはプラグインオプションの型を指定します。
今回はオプションなしなのでneverを指定しています。
Vue.typedSetの型定義
Vue.typedSetの定義は以下のとおりです。
declare module 'vue/types/vue' {
interface VueConstructor {
typedSet<T extends Object, K extends WritableKeys<T>>(object: T, key: K, value: T[K]): T[K];
}
}
ちょっと長めですが、シンプルにすると以下のようになります。
typedSet<T extends Object, K extends keyof T>(object: T, key: K, value: T[K]): T[K];
言語化すると
objectで受け取った引数にあるプロパティのみをkeyで指定でき、keyで指定したプロパティの値と同じ型のみvalueで受け付ける
という実装になっています。
さて、シンプルにする前後で異なるのは、readonlyでないプロパティのみを取得する型 WritableKeys です。
詳しく見ていきましょう。
WritableKeys型
WritableKeys型は以下のような定義になっています。
/** オブジェクトの値の型を取得する */
export type Values<T extends object> = T[keyof T];
/** 2つの型を比較し、一緒ならtrueを異なればfalseを返す */
export type IsEquals<X, Y> =
(<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
/** プロパティがreadonlyかどうかを判定する */
export type IsNotReadonly<T extends Object, P extends keyof T> = IsEquals<{ [K in P]: T[P] }, { -readonly [K in P]: T[P]}>;
/** readonlyでないプロパティのみ取得する */
export type WritableKeys<T extends Object> =
T extends any[] ? number : Values<{
[P in keyof T]-?: IsNotReadonly<T, P> extends true ? P : never;
}>;
ちょっと情報が多いですね。WritableKeys型のみ見てみましょう。
WritableKeys型は、Genericで渡された型が配列であればnumber型を、そうでなければreadonlyではないプロパティのキーを返します。
/** readonlyでないプロパティのキーを取得する */
export type WritableKeys<T extends Object> =
T extends any[] ? number : Values<{
[P in keyof T]-?: IsNotReadonly<T, P> extends true ? P : never;
}>;
まず、型TにはObject型が渡されますが、配列である可能性もあります。
Tが配列である場合(T extends any[])はキーの型としてnumber型を返しています。
ここまでが以下の部分です。
export type WritableKeys<T extends Object> =
T extends any[] ? number : /*
readonlyではないプロパティのキーを取得する
*/;
続いて、readonlyでないプロパティのみを取り出します。
ここでは、 IsNotReadonly型 を使って、readonlyでないプロパティであれば値がプロパティ名に、readonlyなプロパティであれば値がneverになるようなオブジェクト型に変換しています。
{
// Optionalなプロパティは値にundefinedが残るので -? でOptional除去
[P in keyof T]-?: IsNotReadonly<T, P> extends true ? P : never;
}
// 例
interface IFoo {
bar: string;
readonly baz: string;
qux: number;
}
// 以下のような型に変換される
{
bar: 'bar';
baz: never;
qux: 'qux';
}
その上で、 Values型 で値の型のみ取り出しています。
export type WritableKeys<T extends Object> =
/* 配列判定 */ : Values<{
[P in keyof T]-?: IsNotReadonly<T, P> extends true ? P : never;
}>;
// 例
interface IFoo {
bar: string;
readonly baz: string;
qux: number;
}
// 以下のような型に変換される
type keys = WritableKeys<IFoo>; // 'bar' | 'qux'
プロパティのフィルタリング
このようにすこし回りくどい方法でプロパティのフィルタリングを行っているのは、TypeScriptではマッピングとともにプロパティを除去することができないからです。
TypeScriptでは{ [K in keyof T]: any }とkeyofとinを使うことにより、元のオブジェクトと同じキーを持つ別の型を生成することができます。
このとき、マッピングに使えるのがkeyofだけで、キー自体をフィルタリングする方法は今の所ありません。
そこで、回りくどいですが、一度value側に値をセットし、そのvalueの型を取り出すことでキーをフィルタリングしているように見せています。
Vue.typedDeleteの型定義
Vue.typedDeleteの定義は以下のとおりです。
declare module 'vue/types/vue' {
interface VueConstructor {
typedDelete<T extends Object, K extends OptionalKeys<T>>(object: T, key: K): void;
}
}
Vue.typeSetと同じく、シンプルにするとobjectで受け取った引数にあるプロパティのみをkeyで受け取れるという実装になっています。
typedDelete<T extends Object, K extends keyof T>(object: T, key: K): void;
こちらでシンプルにする前後で異なるのはOptionalなプロパティのキーのみを取得する型OptionalKeys です。
詳しく見ていきましょう。
OptionalKeys型
OptionalKeys方は以下のような定義になっています。
/** オブジェクトの値の型を取得する */
export type Values<T extends object> = T[keyof T];
/** 2つの型がいずれもtrueの場合のみtrueを返す */
export type And<X extends boolean, Y extends boolean> =
X extends true
? Y extends true
? true
: false
: false;
/** 2つの型を比較し、一緒ならtrueを異なればfalseを返す */
export type IsEquals<X, Y> =
(<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
/** プロパティがOptionalかどうかを判定する */
export type IsOptional<T extends Object, P extends keyof T> = IsEquals<Pick<T, P>, { [K in P]?: T[P] }>;
/** プロパティがreadonlyかどうかを判定する */
export type IsNotReadonly<T extends Object, P extends keyof T> = IsEquals<{ [K in P]: T[P] }, { -readonly [K in P]: T[P]}>;
/** Optionalなプロパティのみ取得する */
export type OptionalKeys<T extends Object> =
T extends any[] ? number : Values<{
[P in keyof T]-?: And<IsOptional<T, P>, IsNotReadonly<T, P>> extends true ? P : never;
}>;
こちらも情報が多いですね。
OptionalKeys型は、Genericで渡された型が配列であればnumber型を、そうでなければOptionalかつreadonlyではないプロパティのキーを返します。
export type OptionalKeys<T extends Object> =
T extends any[] ? number : Values<{
[P in keyof T]-?: And<IsOptional<T, P>, IsNotReadonly<T, P>> extends true ? P : never;
}>;
valueの条件以外はWritableKeys型と同じです。
条件もOptionalなプロパティ(IsOptional<T, P>)かつ、readonlyではないプロパティ(IsNotReadonly<T, P>)というシンプルなものです。
And<IsOptional<T, P>, IsNotReadonly<T, P>> extends true ? P : never;
vm.$typedSet/vm.$typedDeleteの型定義
最後に、インスタンスメソッドの型定義を行います。
メソッド名が異なるので、全く同じ定義を2度書かなければなりません。
declare module 'vue/types/vue' {
interface Vue {
/** Type-safe version of Vue.set */
$typedSet<T extends Object, K extends WritableKeys<T>>(object: T, key: K, value: T[K]): T[K];
/** Type-safe version of Vue.delete */
$typedDelete<T extends Object, K extends OptionalKeys<T>>(object: T, key: K): void;
}
}
とくに説明することもないですね。
ユーティリティ型
本文中にちょいちょい出てくるユーティリティ型の解説をまとめています。適宜参照してください。
Values型
export type Values<T extends object> = T[keyof T];
type foo = Values<{ foo: 0, bar: 1 }>; // 0 | 1
type bar = Values<{ foo: 0, bar: 'foo' }>; // 0 | 'foo'
この型は、オブジェクトの値の型を取得するためのものです。
オブジェクトの型Tに対してkeyof Tの値を参照しています。
そのためすべてのプロパティ(keyof T)の値の型情報を取り出すことができます。
And型
export type And<X extends boolean, Y extends boolean> =
X extends true
? Y extends true
? true
: false
: false;
この型は、Genericで渡された2つの型の両方がtrueの場合のみtrueを、それ以外の場合はfalseを返すものです。
JavaScriptでの&&と同等の型です。
IsEquals型
export type IsEquals<X, Y> =
(<T>() => T extends X ? 1 : 2) extends
(<T>() => T extends Y ? 1 : 2) ? true : false;
この型はGenericで指定した2つの型が同一かを判定するものです。
[Feature request]type level equal operator · Issue #27024 · microsoft/TypeScript
TypeScriptリポジトリのIssue内で提案された型ですが、正直ちゃんと理解できていません。
挙動を見るに、1つ目のConditional TypeでTの型が型Xとひも付き必ず1となり、2つ目のConditional Typeでは1つ目で確定した型Tと型Yを比較することでXと同じであれば1、異なれば2になる。みたいなことではないかなと思っています(挙動からの憶測です。。)。
詳しい方、この型の解説をコメントしていただきたいです...
IsNotReadonly型
export type IsNotReadonly<T extends Object, P extends keyof T> = IsEquals<{ [K in P]: T[P] }, { -readonly [K in P]: T[P]}>;
この型は、Genericで渡されたオブジェクトの型TとプロパティPについて、Pがreadonlyでない場合にtrueを返すものです。
前述のIsEquals型でもともとのプロパティ({ [K in P]: T[P] })とreadonlyを除去したプロパティ({ -readonly [K in P]: T[P]})とを比較しています。
この比較がtrueであるイコールもともとreadonlyがついていないという判断がつきます。
IsOptional型
export type IsOptional<T extends Object, P extends keyof T> = IsEquals<{ [K in P]: T[P] }, { [K in P]?: T[P] }>;
この型はGenericで渡されたオブジェクトの型TとプロパティPについてPがOptionalなプロパティかどうかを判定しています。
実装内容についてはIsNotReadonly型のOptional版なので詳しい説明は省略します。
型定義のテスト
一応、@lollipop-onl/vue-typed-reactiveでは型定義のテストを書いています。
テストに使用しているのは conditional-type-checks という型ライブラリです。
dsherret/conditional-type-checks: Types for testing TypeScript types.
conditional-type-checksでは、型の比較などに便利な型定義が様々収録されています。
また、asset関数を使えば「型チェックにパスする or 失敗するかどうか」をテストすることができます。
@lollipop-onl/vue-typed-reactiveでの例
import { assert, IsExact, Has, NotHas } from 'conditional-type-checks';
// typedSet - key
/** typedSetで指定可能なキーを取得する */
type TypedSetKeys<T extends Object> = TypedSet<T> extends (object: T, key: infer K, value: any) => any ? K : never;
/** レコード型に対して正しくキーを指定できる */
assert<Has<TypedSetKeys<{ foo: string }>, 'foo'>>(true);
/** readonlyなプロパティに対してキーを指定できない */
assert<NotHas<TypedSetKeys<{ readonly foo: string }>, 'foo'>>(true);
/** 配列に対してnumber型をキーとして指定できる */
assert<IsExact<TypedSetKeys<[1, 2, 3]>, number>>(true);
// typedSet - value
/** typedSetで指定可能な値の型を取得する */
type TypedSetValue<T extends Object, K extends WritableKeys<T>> = TypedSet<T> extends (object: T, key: K, value: infer V) => any ? V : never;
/** Record型に対して値を設定できる */
assert<IsExact<TypedSetValue<{ foo: string }, 'foo'>, string>>(true);
/** Record型のOptionalなプロパティに対して値を設定できる */
assert<IsExact<TypedSetValue<{ foo?: string }, 'foo'>, string>>(true);
// typedDelete
/** typeDeleteで指定可能なキーを取得する */
type TypedDeleteKey<T extends Object> = TypedDelete<T> extends (object: T, key: infer K) => void ? K : never;
/** Optionalでないプロパティのキーを指定できない */
assert<NotHas<TypedDeleteKey<{ foo: string }>, 'foo'>>(true);
/** Optoinalなプロパティのキーを指定できる */
assert<Has<TypedDeleteKey<{ foo?: string }>, 'foo'>>(true);
/** readonlyでOptionalなプロパティのキーを指定できない */
assert<NotHas<TypedDeleteKey<{ readonly foo?: string }>, 'foo'>>(true);
/** 配列に対してnumber型のキーを指定できる */
assert<IsExact<TypedDeleteKey<[1, 2, 3]>, number>>(true);
関数の引数をテストしたい場合などは自前で型定義が必要だったりしますが、型定義にもテストを書けるのはリファクタリングなどがしやすいので助かります。
