始めに
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つもあってなんか発狂したくなりますね・・・。
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, {}, {}, {}, {}>;
}
とりあえず、今回知りたいのは以下のような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!!');
},
},
});
今回見たいものだけを抜粋して整理すると、以下のようになります。
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を継続して使えますね。
export type ExtendedVue<Instance extends Vue, Data, Methods, Computed, Props> =
VueConstructor<CombinedVueInstance<Instance, Data, Methods, Computed, Props> & Vue>;
CombinedVueInstance
は簡単で、単純にDataやMethodsなどを結合しているだけです。
export type CombinedVueInstance<Instance extends Vue, Data, Methods, Computed, Props> =
Data & Methods & Computed & Props & Instance;
引数側の型
続いて引数側のThisTypedComponentOptionsWithRecordProps
について見ていきます。
更にComponentOptions
でラップされて泣きそうになりますが、先に3つ目のThisType
について説明します。これはthisの型を定義しており、this.value
みたいなアクセスができるようになります。CombinedVueInstance
は返り値の型の説明でもした全部の型を結合するやつですね。
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でデフォルトの型が代入されていますが、親からは全部渡されているので今回は無視して大丈夫です。
次から各プロパティについて簡単な順で見ていきます。
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
は以下のような定義になっています。
type DataDef<Data, Props, V> = Data | ((this: Readonly<Props> & V) => Data)
そのまま書いた場合はそれをData
の型にして、メソッドの場合はthisにProps
とVueの型を持ってData
を返すという定義になっています。
そういえばdataプロパティのメソッドではpropsの値も使えましたね。その辺の定義もキチンとしているようです。
computedの推論
computedはAccessors<Computed>
で推論されます。
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の型にはPropsDef
とProps
があるのですが、PropsDef
の方を見れば良さそうです。(よく分かっていませんが、Props
の方はrenderのところで使われていました)
PropsDef
の方は親からはRecordPropsDefinition<Props>
で定義されているので、これの定義をみると以下のようになります。関連する型も載せましたが多いですね・・・。
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というプロパティがありましたが、どうやらテストの時に便利なプロパティらしいです。
補足資料
今回の型推論を理解するために手書きのノートを書いているのでそれも載せておきます。
型推論調査の目的
以上がoptionsを渡した時の推論の流れでした。なかなか難しいですね・・・。
今更ながらこれを調べた理由を説明しますと、以下の2つありました。
プラグインを作った際にoptionsで定義したものを使いたい
僕は定数をtemplate側でも使えるようなプラグインを作ったのですが、Vue.jsのプロパティを今はとりあえずanyにしちゃっていて、できればoptionの設定から見れたないいなぁって思っていました。
@wintyo/vue-constants-injection-plugin - npm
// 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.jsの型定義をみるとRecord<string, any>
になって全く型がつかないのが現状です。
今回調べた感じだとProps
とかを普通に$props
にも適応してあげれば解決しそうですが、元々がanyなので結局エラー扱いにはできないというもどかしさがあります・・・。
これはいつかまた別な記事で書くかもしれません。
2020/04/07追記
こちらにプラグインを自作した話を書きました!
$data, $propsに型をつけるプラグインを作る
終わりに
Vue.jsのoptionsを渡した時の推論の流れについて調べましたが、かなり大変でした・・・。
Vue.js 3.0からはComposition APIがリリースされる予定ですが、こうした背景からoptions APIは使われなくなるのかなぁって思ってきました。ただComposition APIだと$router
とかその辺の注入が一切なくなってしまうので移行は結構大変そうだなぁって思いました。Classベースがネイティブでサポートされたらいいんですけどね・・・。