はじめに
この記事はすべて、strictFunctionTypes
オプションをtrueにしている場合においての話である。TypeScriptは関数のSubtype, SupertypeについてBivariantであるという立場で、実行時エラー可能性を犠牲にしつつ実装者の直感的理解を助けてきた経緯があった。
(=> https://qiita.com/na-o-ys/items/aa56d678cdf0de2bdd79 )
しかし、近年 strictFunctionTypes
のように、より論理に忠実な型システムを取り入れてきている。この流れのなかで、本記事のようなedge caseが生まれることも、無理からぬものである。
論旨
TypeScriptでは、インターフェイスに定義されたメソッドと、インターフェイスに定義された関数プロパティ(関数として定義されたプロパティ)は異なる解釈をされる。
この微妙な挙動の違いが、予期せぬ型のエラーを引き起こす場合がある。
playgroundリンク
実際に本記事に用いたコードは下記より確認できる。
現象の説明
導入
IMethodHolder
は、foo
というひとつのメソッドを持ったインターフェイスだ。
interface IMethodHolder {
foo(arg: string): void
}
IFuncPropHolder
は、foo
というひとつの関数プロパティを持ったインターフェイスだ。
interface IFuncPropHolder {
foo: (arg: string) => void
}
このように、両者は微妙に表記が異なることを確認したい。
同じ挙動を示すケース
さて、まずは最初に出てきた IMethodHolder
を実装したHoge01
を定義してみた。
class Hoge01 implements IMethodHolder {
foo(arg: string) {
return;
}
}
当然ながら、これは問題なくTypeScriptのコンパイルを通過する。
次いで、全く同じ定義ながら、IFuncPropHolder
を実装したHoge02
を定義してみた。
class Hoge02 implements IFuncPropHolder {
foo(arg: string) {
return;
}
}
Hoge02
内のfoo
は、関数プロパティでなくメソッドとして定義した。
この場合でも、TypeScriptのコンパイルを無事通過できる。
挙動が異なる場合
Hoge03
は、foo
メソッドの引数を、string
ではなく、そのサブタイプである"abc"
にして、IMethodHolder
を実装した。
class Hoge03 implements IMethodHolder {
foo(arg: "abc") {
return;
}
}
これは、うまくいく。実装したメソッドの引数が、もとの引数のSubtypeであっても、コンパイルは通る。
一方Hoge04
は、foo
メソッドの引数を、string
ではなく、そのサブタイプである"abc"
にして、IFuncPropHolder
を実装したものである。
class Hoge04 implements IFuncPropHolder {
// コンパイルエラー
foo(arg: "abc") {
return;
}
}
これは、エラーが起きる。実装したメソッドの引数が、もとの関数プロパティのSubtypeである場合、コンパイルは通らない。
この挙動になる理由
まず断っておくが、私はこの現象に遭遇して自身が理解に時間を要したため、類似の事象に遭遇した方のために、発見した知見をシェアしたものであるため、TypeScriptのソースを読んで深い理解をするということはやっていない。
さて、現象を整理したい。
-
Hoge03
は通る: メソッドを実装した場合、その引数がもとのサブタイプであっても通る -
Hoge04
は通らない: 関数プロパティを実装した場合、その引数がもとのサブタイプであると通らない
Hoge03
が通るというのは、本来は型システムの厳密性としては、おかしなことなのだ。
例えば、このTypeScriptの特性を利用して、実行時エラーを起こすことだってできる。
Hoge03が通ることによる実行時エラー可能性
function runFoo(hoge: IMethodHolder, str: string): void {
hoge.foo(str);
}
runFoo(new Hoge03(), "xxxx");
runFoo
のような、IMethodHolder
すべてを受け入れる関数によって、本来Hoge03
に与えられるべきでない引数が与えられるのである。例えばHoge03
のfooメソッド内で、"abc"
という文字列を使い、"abc"
を持つオブジェクトにアクセスしたりすると、エラーになる。
foo(arg: "abc"): void {
obj[arg].foobar = 123;
}
しかし、実際にHoge03
は通るし、そのほうが都合が良いケースも多い。
かつての名残で、Bivariance(関数の引数について、SupertypeまたはSubtypeであればよいとする考え方)を認めているのだろう。
一方で、関数プロパティを実装したHoge04
の場合は、厳密な型システムの論理に従うためか、
コンパイルエラーとなる。実行時エラーの可能性を防ぐ意味もあるといえる。
実際、strictFunctionTypes
をfalseにするとこのエラーはなくなった。
発展1 引数にSupertypeを用いた場合
class Hoge05 implements IMethodHolder {
foo(arg: string | number) {
return;
}
}
class Hoge06 implements IFuncPropHolder {
foo(arg: string | number) {
return;
}
}
この場合は両者ともコンパイルが通る。
-
Hoge05
はBivarianceという性質によって、通る -
Hoge06
はContravarianceという性質によって、通る
のであろう。
発展2 戻り値にSubtype, Supertypeを用いた場合
調査結果は、予想通りではあるが、下記の挙動となった。
- 関数/メソッドの戻り値を、もとのSubtypeにすると、いずれも通る。
- 関数/メソッドの戻り値を、もとのSupertypeにすると、いずれも通らない。
ここでは両者の挙動は同一である。戻り値の場合は、Subtypeのみが許容される。
結論
インターフェイスを実装したクラスを定義する際、
- 元がメソッドならば、その引数に対してBivariantな関係の型が利用可能
- 元が関数プロパティならば、その引数に対してContravarianctな関係の型が利用可能
より平易に言うと、
- 元がメソッドならば、その引数のSubtypeもSupertypeも、どちらも利用した実装ができる
- 元が関数プロパティならば、その引数に対してSupertype(同一も含む)を利用した実装しかできない
である。