TypeScriptでVue.set/deleteを型安全にするライブラリ作った

この記事は[Vue #2 Advent Calendar 2019](https://qiita.com/advent-calendar/2019/javascript)の11日目です。

昨日は @nishihara_zari さんの [jQueryしか書けなかった自分がVueを使えるようになるまでの話と同じような環境の人たちへ](https://qiita.com/nishihara_zari/items/4a31d03711ab520d0c99) でした。

# はじめに

以前、[TypeScriptでVue.setを型安全にしたい](https://qiita.com/simochee/items/4d31414cb612f96a4c9f)という記事を書ました。
今回はその記事で紹介した、`Vue.set`（と`Vue.delete`）を型安全にする実装をライブラリ化したので、実装内容についてご紹介します。

本文だけ読みたいという方は [実装について](#実装について) まで読み飛ばしてください。

# ライブラリのご紹介

実装の紹介の前に、公開したライブラリについてご紹介させていただきます。

[![vtyped.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/127934/7fe68a26-2ac6-6eea-5f13-b5618543b52b.png)](https://www.npmjs.com/package/@lollipop-onl/vue-typed-reactive)

> [@lollipop-onl/vue-typed-reactive - npm](https://www.npmjs.com/package/@lollipop-onl/vue-typed-reactive)

`@lollipop-onl/vue-typed-reactive`はVueのグローバルに`Vue.typedSet`と`Vue.typedDelete`を、インスタンスに`vm.$typedSet`と`vm.$typedDelete`を提供するプラグインです。

```sh
$ yarn add @lollipop-onl/vue-typed-reactive
# or
$ npm install -S @lollipop-onl/vue-typed-reactive
```

## 使い方

使い方は簡単で、Vueへの登録と`tsconfig.json`への設定の追加のみです。

```ts
// Vueへの登録
import Vue from 'vue';
import TypedReactive from '@lollipop-onl/vue-typed-reactive';

Vue.use(TypedReactive);
```

<details><summary>Nuxtの場合</summary>
<p>

Nuxtの場合はプラグイン化して登録するのがおすすめです。

```ts
// @/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;
```

```ts
import { Configuration } from '@nuxt/types';

const config: Configuration = {
  ...
  plugins: [
    '@/plugins/libs/vue-typed-reactive',
  ],
  ...
};
```

---

</p>
</details>

`tsconfig.json`では、`types`オプションにこのライブラリを追加してください。

```js
// tsconfig.json
{
  "compilerOptions": {
    "types": [
      "@lollipop-onl/vue-typed-reactive"
    ]
  }
}
```

あとは、Vueコンポーネント内であれば`this.$typedSet`を、VuexのMutationの中などであれば`Vue.typedSet`を使用するようにするだけです。

```ts
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!
  }
}
```

```ts
// 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なプロパティのキーのみ削除できるようにしました。

```ts
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を拡張してます。

```ts
// グローバルメソッド 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プラグイン形式のオブジェクトでラップしています。

```ts
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;
  },
};
```

**[🔗 実際の実装箇所](https://github.com/lollipop-onl/vue-typed-reactive/blob/bf9b8afb4976cd6825944c023de6c957c890ecb3/src/index.ts#L20-L30)**

`PluginObject`はVueプラグインの型情報です。Genericにはプラグインオプションの型を指定します。
今回はオプションなしなので`never`を指定しています。

> [プラグイン — Vue.js](https://jp.vuejs.org/v2/guide/plugins.html#%E3%83%97%E3%83%A9%E3%82%B0%E3%82%A4%E3%83%B3%E3%81%AE%E8%A8%98%E8%BF%B0)

## `Vue.typedSet`の型定義

`Vue.typedSet`の定義は以下のとおりです。

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

**[🔗 実際の実装箇所](https://github.com/lollipop-onl/vue-typed-reactive/blob/bf9b8afb4976cd6825944c023de6c957c890ecb3/src/index.ts#L13-L14)**

ちょっと長めですが、シンプルにすると以下のようになります。

```ts
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`型は以下のような定義になっています。

```ts
/** オブジェクトの値の型を取得する */
export type Values<T extends object> = T[keyof T];

/** ２つの型を比較し、一緒なら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`ではないプロパティのキーを返します。

```ts
/** readonlyでないプロパティのキーを取得する */
export type WritableKeys<T extends Object> = 
  T extends any[] ? number : Values<{
    [P in keyof T]-?: IsNotReadonly<T, P> extends true ? P : never;
  }>;
```

**[🔗 実際の実装箇所](https://github.com/lollipop-onl/vue-typed-reactive/blob/bf9b8afb4976cd6825944c023de6c957c890ecb3/src/utils.ts#L23-L26)**

まず、型`T`には`Object`型が渡されますが、配列である可能性もあります。
`T`が配列である場合（`T extends any[]`）はキーの型として`number`型を返しています。
ここまでが以下の部分です。

```ts
export type WritableKeys<T extends Object> = 
  T extends any[] ? number : /*
    readonlyではないプロパティのキーを取得する
  */;
```

続いて、`readonly`でないプロパティのみを取り出します。

ここでは、 [`IsNotReadonly`型](#isnotreadonly型) を使って、`readonly`でないプロパティであれば値がプロパティ名に、`readonly`なプロパティであれば値が`never`になるようなオブジェクト型に変換しています。

```ts
{
  // 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`型](#values型) で値の型のみ取り出しています。

```ts
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`の定義は以下のとおりです。

```ts
declare module 'vue/types/vue' {
  interface VueConstructor {
    typedDelete<T extends Object, K extends OptionalKeys<T>>(object: T, key: K): void;
  }
}
```

**[🔗 実際の実装箇所](https://github.com/lollipop-onl/vue-typed-reactive/blob/bf9b8afb4976cd6825944c023de6c957c890ecb3/src/index.ts#L15-L16)**

`Vue.typeSet`と同じく、シンプルにすると`object`で受け取った引数にあるプロパティのみを`key`で受け取れるという実装になっています。

```ts
typedDelete<T extends Object, K extends keyof T>(object: T, key: K): void;
```

こちらでシンプルにする前後で異なるのはOptionalなプロパティのキーのみを取得する型`OptionalKeys `です。
詳しく見ていきましょう。

### `OptionalKeys`型

`OptionalKeys`方は以下のような定義になっています。

```ts
/** オブジェクトの値の型を取得する */
export type Values<T extends object> = T[keyof T];

/** ２つの型がいずれもtrueの場合のみtrueを返す */
export type And<X extends boolean, Y extends boolean> =
  X extends true
    ? Y extends true
      ? true
      : false
    : false;

/** ２つの型を比較し、一緒なら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`ではないプロパティのキーを返します。

```ts
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;
  }>;
```

**[🔗 実際の実装箇所](https://github.com/lollipop-onl/vue-typed-reactive/blob/bf9b8afb4976cd6825944c023de6c957c890ecb3/src/utils.ts#L28-L32)**

`value`の条件以外は`WritableKeys`型と同じです。

条件もOptionalなプロパティ（`IsOptional<T, P>`）かつ、`readonly`ではないプロパティ（`IsNotReadonly<T, P>`）というシンプルなものです。

```ts
And<IsOptional<T, P>, IsNotReadonly<T, P>> extends true ? P : never;
```

* [`And`型](#and型)
* [`IsOptional`型](#isoptional型)
* [`IsNotReadonly`型](#isnotreadonly型)

## `vm.$typedSet`/`vm.$typedDelete`の型定義

最後に、インスタンスメソッドの型定義を行います。

メソッド名が異なるので、全く同じ定義を２度書かなければなりません。

```ts
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;
  }
}
```

**[🔗 実際の実装箇所](https://github.com/lollipop-onl/vue-typed-reactive/blob/bf9b8afb4976cd6825944c023de6c957c890ecb3/src/index.ts#L5-L10)**

とくに説明することもないですね。

## ユーティリティ型

本文中にちょいちょい出てくるユーティリティ型の解説をまとめています。適宜参照してください。

### `Values`型

```ts
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'
```

**[🔗 実際の実装箇所](https://github.com/lollipop-onl/vue-typed-reactive/blob/bf9b8afb4976cd6825944c023de6c957c890ecb3/src/utils.ts#L1-L2)**

この型は、オブジェクトの値の型を取得するためのものです。

オブジェクトの型`T`に対して`keyof T`の値を参照しています。
そのためすべてのプロパティ（`keyof T`）の値の型情報を取り出すことができます。

### `And`型

```ts
export type And<X extends boolean, Y extends boolean> =
  X extends true
    ? Y extends true
      ? true
      : false
    : false;
```

**[🔗 実際の実装箇所](https://github.com/lollipop-onl/vue-typed-reactive/blob/bf9b8afb4976cd6825944c023de6c957c890ecb3/src/utils.ts#L4-L10)**

この型は、Genericで渡された２つの型の両方が`true`の場合のみ`true`を、それ以外の場合は`false`を返すものです。
JavaScriptでの`&&`と同等の型です。

### `IsEquals`型

```ts
export type IsEquals<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends
  (<T>() => T extends Y ? 1 : 2) ? true : false;
```

**[🔗 実際の実装箇所](https://github.com/lollipop-onl/vue-typed-reactive/blob/bf9b8afb4976cd6825944c023de6c957c890ecb3/src/utils.ts#L12-L14)**

この型はGenericで指定した２つの型が同一かを判定するものです。

> [[Feature request]type level equal operator · Issue #27024 · microsoft/TypeScript](https://github.com/Microsoft/TypeScript/issues/27024#issuecomment-421529650)

TypeScriptリポジトリのIssue内で提案された型ですが、正直ちゃんと理解できていません。

挙動を見るに、１つ目のConditional Typeで`T`の型が型`X`とひも付き必ず`1`となり、２つ目の`Conditional Type`では１つ目で確定した型`T`と型`Y`を比較することで`X`と同じであれば`1`、異なれば`2`になる。みたいなことではないかなと思っています（挙動からの憶測です。。）。

**詳しい方、この型の解説をコメントしていただきたいです...**

### `IsNotReadonly`型

```ts
export type IsNotReadonly<T extends Object, P extends keyof T> = IsEquals<{ [K in P]: T[P] }, { -readonly [K in P]: T[P]}>;
```

**[🔗 実際の実装箇所](https://github.com/lollipop-onl/vue-typed-reactive/blob/bf9b8afb4976cd6825944c023de6c957c890ecb3/src/utils.ts#L19-L20)**

この型は、Genericで渡されたオブジェクトの型`T`とプロパティ`P`について、`P`が`readonly`でない場合に`true`を返すものです。

前述の[`IsEquals`型](#isequals型)でもともとのプロパティ（`{ [K in P]: T[P] }`）と`readonly`を除去したプロパティ（`{ -readonly [K in P]: T[P]}`）とを比較しています。
この比較が`true`であるイコールもともと`readonly`がついていないという判断がつきます。

### `IsOptional`型

```ts
export type IsOptional<T extends Object, P extends keyof T> = IsEquals<{ [K in P]: T[P] }, { [K in P]?: T[P] }>;
```

**[🔗 実際の実装箇所](https://github.com/lollipop-onl/vue-typed-reactive/blob/bf9b8afb4976cd6825944c023de6c957c890ecb3/src/utils.ts#L16-L17)**

この型はGenericで渡されたオブジェクトの型`T`とプロパティ`P`について`P`がOptionalなプロパティかどうかを判定しています。

実装内容については[`IsNotReadonly`型](#isnotreadonly型)のOptional版なので詳しい説明は省略します。

## 型定義のテスト

一応、`@lollipop-onl/vue-typed-reactive`では型定義のテストを書いています。

**[🔗 実際の実装箇所](https://github.com/lollipop-onl/vue-typed-reactive/blob/master/tests/index.ts)**

テストに使用しているのは `conditional-type-checks` という型ライブラリです。 

> [dsherret/conditional-type-checks: Types for testing TypeScript types.](https://github.com/dsherret/conditional-type-checks)

`conditional-type-checks`では、型の比較などに便利な型定義が様々収録されています。
また、`asset`関数を使えば「型チェックにパスする or 失敗するかどうか」をテストすることができます。

**`@lollipop-onl/vue-typed-reactive`での例**

```ts
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);
```

関数の引数をテストしたい場合などは自前で型定義が必要だったりしますが、型定義にもテストを書けるのはリファクタリングなどがしやすいので助かります。

# 出典

* [typescript2.8 - TypeScript conditional types - filter out readonly properties / pick only required properties - Stack Overflow](https://stackoverflow.com/questions/49579094/typescript-conditional-types-filter-out-readonly-properties-pick-only-requir)