Help us understand the problem. What is going on with this article?

Vue.js options APIの型推論について調べてみた

始めに

Vue.jsは今だとVue.extendを呼ぶことでobject形式でも型が利くようになり、クラス形式じゃなくてもTypeScriptは機能するようになってきました。
ただ具体的な原理が分からなかったので調べてみることにしました。バージョンは2.6.11をみています。

import Vue from 'vue';

export default Vue.extend({
  data() {
    return {
      value: 10,
    };
  },
  mounted() {
    this.value.slice();  // number型なのでエラー!
  },
});

型推論の基本原則

推論の流れをみていく前に、基本的なところをおさらいします。

generics

型推論はgenericsを使ってパラメータから何の型かを推論します。
Tが何らかの型で、それは受け取ったパラメータに応じて変化します。

function head<T>(arr: Array<T>): T | undefined {
  return arr[0];
}

// number型と解釈される
const num = head([1, 2, 3]);
// string型と解釈される
const str = head(['one', 'two', 'three']);

// 型が分からない場合は明示的に書くこともできる
const list: any = [1, 2, 3];
const item = head<number>(list);

オーバーロード

同じメソッド名を定義して、引数に応じて対応したメソッドの定義を参照します。

class Twicer {
  // オーバーロードメソッドのインターフェイス
  twice(num: number): number // 数を2倍する処理のインターフェイス
  twice(str: string): string // 文字列を2回繰り返す処理のインターフェイス
  twice<T>(arr: T[]): T[]    // 配列要素を2倍にする処理のインターフェイス

  twice(value: any): any {
    // 具体的な実装
  }
}

const twicer = new Twicer();
twicer.twice(10);        // twice(num: number): numberをみる
twicer.twice('abc');     // twice(str: string): stringをみる
twicer.twice([1, 2, 3]); // twice<T>(arr: T[]): T[]をみる(ここではT=number)

実装は共通のメソッド一つでやらないといけないとか、TypeScriptだと色々制約があるのですが、今回の話とは関係ないので詳細は以下などを参照してください。
TypeScript: オーバーロードメソッドを定義する方法 - Qiita

Vue.jsの推論

ここから推論の流れについてみていきたいと思います。
Vue.extendの型定義はこちらになります。定義が長い上オーバーロードが5つもあってなんか発狂したくなりますね・・・。

extendの定義
export interface VueConstructor<V extends Vue = Vue> {
  // propsが名前指定の場合
  extend<Data, Methods, Computed, PropNames extends string = never>(options?: ThisTypedComponentOptionsWithArrayProps<V, Data, Methods, Computed, PropNames>): ExtendedVue<V, Data, Methods, Computed, Record<PropNames, any>>;
  // propsをオブジェクトで指定した場合(これを調べる)
  extend<Data, Methods, Computed, Props>(options?: ThisTypedComponentOptionsWithRecordProps<V, Data, Methods, Computed, Props>): ExtendedVue<V, Data, Methods, Computed, Props>;
  // 関数型のpropsが名前指定の場合
  extend<PropNames extends string = never>(definition: FunctionalComponentOptions<Record<PropNames, any>, PropNames[]>): ExtendedVue<V, {}, {}, {}, Record<PropNames, any>>;
  // 関数型のpropsをオブジェクトで指定した場合
  extend<Props>(definition: FunctionalComponentOptions<Props, RecordPropsDefinition<Props>>): ExtendedVue<V, {}, {}, {}, Props>;
  // それ以外
  extend(options?: ComponentOptions<V>): ExtendedVue<V, {}, {}, {}, {}>;
}

https://github.com/vuejs/vue/blob/v2.6.11/types/vue.d.ts#L86-L90

とりあえず、今回知りたいのは以下のようなoptionを渡したことにしたいので、2番目のextendの型について調べたいと思います。

import Vue from 'vue';

export default Vue.extend({
  props: {
    hoge: String,
  },
  data() {
    return {
      value: 10,
    };
  },
  computed: {
    _double() {
      return 2 * this.value;
    },
  },
  methods: {
    action() {
      console.log('Action!!');
    },
  },
});

今回見たいものだけを抜粋して整理すると、以下のようになります。

extendの定義
export interface VueConstructor<V extends Vue = Vue> {
  // propsをオブジェクトで指定した場合
  extend<Data, Methods, Computed, Props>(
    options?: ThisTypedComponentOptionsWithRecordProps<V, Data, Methods, Computed, Props>
  ): ExtendedVue<V, Data, Methods, Computed, Props>;
}
  • Data, Methods, Computed, Propsを推論
    • 推論するためにThisTypedComponentOptionsWithRecordPropsでラップ
  • ExtendedVueを返す

推論する道のりがあまりにも長いので、上記のパラメータを渡した際に推論された結果を頭に入れた上で話を進めていきたいと思います。

推論された結果
Data = { value: number };
Methods = { action(): void };
Computed = { _double: number };
Props = { hoge: string };

返り値の型

まず返ってくる型を見ます。元々のVueの情報に推論された結果を結合して同じVueConstructorを返します。これでextendを継続して使えますね。

ExtendedVue
export type ExtendedVue<Instance extends Vue, Data, Methods, Computed, Props> =
  VueConstructor<CombinedVueInstance<Instance, Data, Methods, Computed, Props> & Vue>;

CombinedVueInstanceは簡単で、単純にDataやMethodsなどを結合しているだけです。

CombinedVueInstance
export type CombinedVueInstance<Instance extends Vue, Data, Methods, Computed, Props> =
  Data & Methods & Computed & Props & Instance;

引数側の型

続いて引数側のThisTypedComponentOptionsWithRecordPropsについて見ていきます。
更にComponentOptionsでラップされて泣きそうになりますが、先に3つ目のThisTypeについて説明します。これはthisの型を定義しており、this.valueみたいなアクセスができるようになります。CombinedVueInstanceは返り値の型の説明でもした全部の型を結合するやつですね。

ThisTypedComponentOptionsWithRecordPropsの型定義
export type ThisTypedComponentOptionsWithRecordProps<V extends Vue, Data, Methods, Computed, Props> =
  object &
  ComponentOptions<V, DataDef<Data, Props, V>, Methods, Computed, RecordPropsDefinition<Props>, Props> &
  // this.~で書ける型の定義
  ThisType<CombinedVueInstance<V, Data, Methods, Computed, Readonly<Props>>>;

さらに入ってComponentOptionsの定義をみます。ここでようやくdataとか、propsとか見慣れたプロパティを目にします。ここで書かれた内容が推論されることになります。
genericsでデフォルトの型が代入されていますが、親からは全部渡されているので今回は無視して大丈夫です。
次から各プロパティについて簡単な順で見ていきます。

ComponentOptions
export interface ComponentOptions<
  V extends Vue,
  Data = DefaultData<V>,
  Methods = DefaultMethods<V>,
  Computed = DefaultComputed,
  PropsDef = PropsDefinition<DefaultProps>,
  Props = DefaultProps
> {
  data?: Data;
  props?: PropsDef;
  propsData?: object;
  computed?: Accessors<Computed>;
  methods?: Methods;

  // 他は省略
}

methodsの推論

methodsが一番簡単で、methodsプロパティに書かれた内容がそのままMethodsの型として推論されます。

// こういうプロパティだと
{
  methods: {
    action() {
      console.log('Action!!');
    },
  },
}

// こういう風に推論される
Methods = { action(): void };

余談ですが何もチェックしていないのでここに値をセットしてもエラーにならないですね(笑)。genericsのMethodsに何かしらextendsしていたらメソッドだけとか縛れそうですけどね。

{
  methods: {
    hoge: 'not error!',  // エラーにならない
    action() {
      console.log('Action!!');
    },
  },
}

dataの推論

Dataって書かれてますが、実際親から呼ばれているのはDataDefになっているので注意してください。
そのDataDefは以下のような定義になっています。

DataDef
type DataDef<Data, Props, V> = Data | ((this: Readonly<Props> & V) => Data)

そのまま書いた場合はそれをDataの型にして、メソッドの場合はthisにPropsとVueの型を持ってDataを返すという定義になっています。

そういえばdataプロパティのメソッドではpropsの値も使えましたね。その辺の定義もキチンとしているようです。

computedの推論

computedはAccessors<Computed>で推論されます。
Accessorsの定義は以下です。

Accessors
export type Accessors<T> = {
  [K in keyof T]: (() => T[K]) | ComputedOptions<T[K]>
}

export interface ComputedOptions<T> {
  get?(): T;
  set?(value: T): void;
  cache?: boolean;
}

各プロパティにおいてメソッドで書くかget, setのようなオブジェクトを更に含めるかの判定をしているようです。よくこれで推論できますね・・・。
余談ですが、推論するのが難しいため返り値には型を書くように推奨されていますね。
TypeScript のサポート — Vue.js

propsの推論

最後にpropsですが、推論の道は長いです(汗)。
まずgenericsの型にはPropsDefPropsがあるのですが、PropsDefの方を見れば良さそうです。(よく分かっていませんが、Propsの方はrenderのところで使われていました)
PropsDefの方は親からはRecordPropsDefinition<Props>で定義されているので、これの定義をみると以下のようになります。関連する型も載せましたが多いですね・・・。

RecordPropsDefinition
export type RecordPropsDefinition<T> = {
  // なぜかqiita上で表示されなかったので先頭に#をつけています。実際はないので注意してください。
# [K in keyof T]: PropValidator<T[K]>;
}

export type PropValidator<T> = PropOptions<T> | PropType<T>;

export interface PropOptions<T=any> {
  type?: PropType<T>;
  required?: boolean;
  default?: T | null | undefined | (() => T | null | undefined);
  validator?(value: T): boolean;
}

export type PropType<T> = Prop<T> | Prop<T>[];

export type Prop<T> =
  { (): T } |
  { new(...args: never[]): T & object } |
  { new(...args: string[]): Function }

詳細は省きますが、最終的にはProp<T>のところまで行き、コンストラクタの型をみることになります。

ちなみにProp<T>の型定義があんまりよくなくって、1つ目の{ (): T }がコンストラクタじゃないせいでJSのDate型はstring型と解釈されてしまいます・・・。

余談

propsDataというプロパティがありましたが、どうやらテストの時に便利なプロパティらしいです。

https://qiita.com/ykhirao/items/43397531e58664ef4645

補足資料

今回の型推論を理解するために手書きのノートを書いているのでそれも載せておきます。

3b46028f-5efe-4355-bd34-a759db9c4405.jpg
120afc6f-fefa-447d-ac77-e985652d3db1.jpg
beb1e0a5-0789-445f-92a8-65d08afe0de6.jpg

型推論調査の目的

以上がoptionsを渡した時の推論の流れでした。なかなか難しいですね・・・。
今更ながらこれを調べた理由を説明しますと、以下の2つありました。

プラグインを作った際にoptionsで定義したものを使いたい

僕は定数をtemplate側でも使えるようなプラグインを作ったのですが、Vue.jsのプロパティを今はとりあえずanyにしちゃっていて、できればoptionの設定から見れたないいなぁって思っていました。

@wintyo/vue-constants-injection-plugin - npm

vue-constants-injection-pluginの型定義
// extend option
declare module 'vue/types/options' {
  interface ComponentOptions<V extends Vue> {
    C?: {
      [key: string]: any;
    }
  }
}

// extend property
declare module 'vue/types/vue' {
  interface Vue {
    $C: {
      // できればComponentOptionsから定義したものを使いたい!
      [key: string]: any;
    };
  }
}

まぁ今回調べた結果だとほぼ無理ですね(笑)。extendsを更にオーバーロードさせたらもしかしたらできるかもしれませんが、あれ以上カオスにはしたくないですね・・・。

$data, $propsに型をつけたい

昔以下の記事を投稿しましたが、$data, $propsを使っていきたいという気持ちをずっと持ち続けています。

Vueのプロパティを分かりやすくする - Qiita

しかしVue.jsの型定義をみるとRecord<string, any>になって全く型がつかないのが現状です。

https://github.com/vuejs/vue/blob/v2.6.11/types/vue.d.ts#L33-L34

今回調べた感じだとPropsとかを普通に$propsにも適応してあげれば解決しそうですが、元々がanyなので結局エラー扱いにはできないというもどかしさがあります・・・。
これはいつかまた別な記事で書くかもしれません。

終わりに

Vue.jsのoptionsを渡した時の推論の流れについて調べましたが、かなり大変でした・・・。
Vue.js 3.0からはComposition APIがリリースされる予定ですが、こうした背景からoptions APIは使われなくなるのかなぁって思ってきました。ただComposition APIだと$routerとかその辺の注入が一切なくなってしまうので移行は結構大変そうだなぁって思いました。Classベースがネイティブでサポートされたらいいんですけどね・・・。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした