7
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2019-12-10

この記事はVue #2 Advent Calendar 2019の11日目です。

昨日は @nishihara_zari さんの jQueryしか書けなかった自分がVueを使えるようになるまでの話と同じような環境の人たちへ でした。

はじめに

以前、TypeScriptでVue.setを型安全にしたいという記事を書ました。
今回はその記事で紹介した、Vue.set(とVue.delete)を型安全にする実装をライブラリ化したので、実装内容についてご紹介します。

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

ライブラリのご紹介

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

vtyped.png

@lollipop-onl/vue-typed-reactive - npm

@lollipop-onl/vue-typed-reactiveはVueのグローバルにVue.typedSetVue.typedDeleteを、インスタンスにvm.$typedSetvm.$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.typedSetVue.setの完全なエイリアスなので、全て置き換えても正常に動作すると思います(deleteも同様)。

機能

typedSettypedDeleteいずれも、プロパティと値の型安全以外にも 余計な 機能をつけました。

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.setVue.deleteで回避することができます。

恩恵

実際に開発に組み込んでみて、VuexストアのMutationでとても活躍しています。
VuexのモジュールモードはモジュールがネストするとMutation内でのVue.setの使用が必須となります。

せっかくTypeScriptを使っているのにStateへの代入時の型不整合がチェックできないのはそこそこしんどいと思っていました。
また、プロパティ名の変更などもVue.setVue.deleteでは検知できないので手動リファクタが必要なってきます。

そういうのって面倒ですよね。

Vue.typedSetVue.typedDeleteを使ってよりリファクタリングのしやすいVueアプリにしませんか?

実装について

ここからは、vue-typed-reactiveの実装内容についてご紹介します。

以降はほぼほぼTypeScriptに関する内容になります。
Vueのアドベントカレンダーなのにすみません。。

Vueの拡張

Vue.typedSetVue.typedDeleteというメソッドはVueには存在しません。
それぞれVue.setVue.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.js

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 }keyofinを使うことにより、元のオブジェクトと同じキーを持つ別の型を生成することができます。

このとき、マッピングに使えるのが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について、Preadonlyでない場合に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);

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

出典

7
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?