この記事は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);
関数の引数をテストしたい場合などは自前で型定義が必要だったりしますが、型定義にもテストを書けるのはリファクタリングなどがしやすいので助かります。