概要
https://www.typescriptlang.org/tsconfig#strictFunctionTypes
{
"strictFunctionTypes": true
}
公式リリースノート:https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2–6.html
関数代入時の引数の型チェックにおいて、TypeScript のデフォルトは Bivariantly な挙動だが、このオプションをtrue
にすると Contravariantly に型チェックが走るようになります。
Variance については下記の記事が参考になります。
- ジェネリックの共変性と反変性 - Microsoft .NET
- Type Compatibility - TypeScript Deep Dive 日本語版
- TypeScript 2.6 変更点と注意点 - abcdefGets
Variance (バリアンス)とは?
Variance とは、端的に言えば「型の違う変数同士を代入する際のルール」のことです。
(ちなみにこの概念自体は TypeScript 固有のものではなく、プログラミング言語において一般的に使用される概念です。)
ここで言う「型の違う」というのは、基本的には継承関係にある親子のクラス型間の話です。
つまり、string
とnumber
は継承関係に無いので、それらの型を持った変数同士が互いに代入不可能なのは当然です。
しかし、継承関係にある変数というのは、型が違っても代入可能な場合があります。
オブジェクト指向でポリモーフィズムと呼んでいる仕組みがまさにそれで、親クラス型の変数には、子クラス型の変数を代入することでき、これによって柔軟なコードを書くことができるようになります(下記のコードは仮想の言語で、雰囲気)。
// ポリモーフィズムの実装ができる言語では...
class SmartPhone {
call() { /* 電話をかける処理(このクラスを継承する子クラス全てに共通の、処理またはインターフェース) */ }
}
class iOSSmartPhone extends SmartPhone {
purchaseOnAppStore() { /* AppStoreで課金する処理(子クラスにしか存在しない処理) */ }
}
class AndroidSmartPhone extends SmartPhone {
purchaseOnPlayStore() { /* PlayStoreで課金する処理(子クラスにしか存在しない処理) */ }
}
// それぞれ型が違うオブジェクトを生成する
iOSSmartPhone iosSp = new iOSSmartPhone();
AndroidSmartPhone androidSp = new AndroidSmartPhone();
// それらを一つの親の型の配列に代入できる
SmartPhone[] sps = [ iosSp, androidSp ];
// OSが異なるスマホでも、callメソッドは共通しているため全部一括でcallメソッドを実行できる。
for (int i = 0; i < sps.length; i++) {
sps[i].call();
}
この、「親クラス型の変数には子クラス型の変数を代入できるルール」のことを、Variance の考え方ではCovariant(もしくは Covariance)と呼びます。
Variance には Covariant を含めた 4 つの種類が存在します。
- Covariant/Covariance: 親クラス型の変数には、子クラス型の変数を代入できる。
- Contravariant/Contravariance: 子クラス型の変数には、親クラス型の変数を代入できる。
- Bivariant/Bivariance: 継承関係にあるクラス同士であれば、親でも子でも互いに代入できる。
- Invariant/Invariance: 継承関係にあっても、型が異なれば代入はできない。
関数代入時の引数の型チェックの挙動は Bivariant
話を戻すと、TypeScript では、関数代入時の引数の型チェックの挙動はデフォルトでBivariantです。
補足:関数同士の代入において必須の条件
- 関数の返り値は、代入先の関数の型の返り値型を全て満たしている。
- 余分にある分には構わない。
- 引数の数は、代入先の関数の引数の数以上である。
- 多い分には構わない。
- オプション引数(
?
つき)でも、個数を満たしていれば代入可能。 - 可変長引数の場合は相手の引数に数に関わらず代入可能。
感覚的には Covariant じゃないんだ、と思うんですが、実際に書いてみると関数の引数に関しては Covariant が危険であることがわかります。
class SmartPhone {
// このクラスを継承するクラス全てに共通のメソッド
call() {
console.log("Calling...");
}
}
class iOSSmartPhone extends SmartPhone {
// 子クラス特有のメソッド
openAppStore() {
console.log("Opened!");
}
}
let openAppStore: (sp: iOSSmartPhone) => void = (sp) => sp.openAppStore();
let callBySmartPhone: (sp: SmartPhone) => void = (sp) => sp.call();
の時に、
// デフォルトではOK、`strictFunctionTypes: true`の時にはError
callBySmartPhone = openAppStore;
// RuntimeError: 型定義的には親クラスのインスタンスを渡すのが正解だが、
// 実際の中身の処理ではそのインスタンスに対して子クラス特有のメソッドを呼び出しているため
callBySmartPhone(new SmartPhone());
// OK
openAppStore = callBySmartPhone;
// OK: 型定義的には子クラスのインスタンスを渡すが、
// 中の実際の処理では親クラスのメソッドを呼び出している。特に問題なし。
openAppStore(new iOSSmartPhone());
となるからです。
strictFunctionTypes
をtrue
にすることで、この危険な Covariant な関数代入を静的型チェックの段階でエラーにすることが出来ます。
TypeScript は自由を求めてデフォルトでこの手のルールではゆるい方の挙動を取っていますが、実際にはランタイムエラーを起こしうるので静的にチェックしてほしいところ。
つまり、これもとりあえずtrue
にしておきましょう。
そもそもの話、Immutable を意識してconst
だけ使っていれば通常気にする必要は無い話だとは思います。
ちなみに Contravariant も危険だっていう話
実は、代入が参照渡しの時点で、Contravariant もランタイムエラーを引き起こす可能性があります。
https://typescript-jp.gitbook.io/deep-dive/type-system/type-compatibility
ここを読んでいて面白かったので紹介します。
animalArr = catArr; // Okay if covariant
animalArr.push(new Animal("another animal")); // Just pushed an animal into catArr!
catArr.forEach((c) => c.meow()); // Allowed but BANG 🔫 at runtime
この部分。
Cat
はAnimal
クラスの子クラスで、animalArr
、catArr
はそれぞれAnimal
の配列とCat
の配列。
1 行目で、親クラスの配列型に対して、子クラスの配列型を代入しています。
これは Covariant なので大丈夫なように感じるんですが、配列の場合参照渡しになるので、animalArr = catArr;
の後にanimalArr.push()
をすると、catArr
にも要素が追加されます(というかどっちも同じ配列を参照してる)。
ので、その配列に親クラス(Animal
)型の要素をpush
した後に、うっかりcatArr
に対してforEach
で子クラス(Cat
)特有のメソッドを呼び出すと、型定義は間違っていないのに、配列の中に親クラスのインスタンスが入っているため、ランタイムエラーになります。
ひえ〜
JavaScript のようなミュータブル(変更可能)なデータの存在下で、完全に健全な型システムのためには invariant が唯一有効なオプションです。
ということですね。