TypeScriptの型付おもしろいですよね。
知っている人にとってはどうってことない、知らない人にとっては腑に落ちるところがあるかもしれない。
そんな風に思ったので記事にしてみることにしました。
対象読者
内容としてはこの書籍に書いてあるようなことなので、すでに読了されている方はブラウザバックしてもらって大丈夫だと思います。
基本的には静的型付け・TypeScript初心者の方にとって役立つような内容を目指しました!
型エラーに悩まされているTypeScript初心者
こういうTypeScriptエラーが出てきたときに 「割り当てることができる」 という言葉の意味の説明に困ってしまう方。
こういうエラーが出てきたときにげっそりしてしまう方などです。
誤解のないように訂正しますが、「型エラーに悩まされるのは初心者だけ」というわけじゃないです。
TypeScriptは触ったことがない静的型言語経験者
TypeScriptを触り始めたけれど、これまで触ってきた静的型付け言語とちょっと様子が違くって戸惑っているという方。
TypeScriptの型付けはJavaなどの言語とはちょっと違うので、いったん他の言語のことは忘れて本記事を読んでみてください。
TypeScriptにおける型の割当戦略
「型Aが型Bに割当可能」とは「型Bが型Aのサブタイプである」ということ
これが大原則です。
静的型付け言語でコーディングするにあたって型の「割り当て可能性」がどのように判定されるかを理解することは大切ですね。
ただし、同じ静的型付けという性質をもっている言語間でも、各々違いがあったりします。
TypeScriptの割り当て可能性を理解するにあたって、個人的に重要だと感じているポイントはTypeScriptにおける「サブタイプ」に対する理解を深めることだと感じています。
TypeScriptにはReturnType, Excludeなど型定義を助ける組み込みの条件型も多数ありますが、これらの挙動を理解するのにもサブタイプの視点は役立ちます。
TypeScriptと他言語との型割当戦略の違い
プログラミング言語には様々な静的型付けのものが存在します。
TypeScriptとそのほかの言語では、同じ静的型付けとはいえ型割当の戦略が異なる場合があります。
まずはそれぞれの型割当戦略の違いを見ていきましょう。
公称型(Java, C++)
TypeScript以外の多くの静的型付け言語は、「公称型(Nominal Typing)」を採用しています。
下記は公称型を作用している言語、Dartのコード例です。
(Javaは書けないので、Javaにシンタックスが近い(と聞いている)Dartをチョイスしました。)
この割当戦略では、クラスの名前と継承関係によって型の割当を判定します。
そのため、例えば以下のようなコードの場合、 Animal型 を継承している Dog型 は rideOn 関数に割り当てることができますが、 Vehicle型 は割り当てることができません。
構造的部分型(TypeScript, Go)
TypeScriptで採用している型の割当戦略は「構造的部分型(Structural Typing)」と呼ばれています。
こちらも、実際のコードを見ながら説明していきます。
以下のTypeScriptのコードを見ると、他の静的型付け言語に慣れ親しんでいる人からすると不自然に思われるかもしれない箇所があります。
25行目の rideOn 関数を見てください。
この関数は、搭乗者名(name)と動物(animal)を引数に取る関数で、内部的には Animal クラスのメソッドである Animal.prototype.run を実行しています。
ところが、33行目を見るとこの rideOn 関数に Vehicle 型のインスタンスを渡していることに気づきます。
各人が持つ言語背景によって感じ方も違うと思いますが、これってなんかおかしいですね 🤔
でも、TypeScriptの世界ではこれは全く問題ありません。
なぜなら、Vehicle型は「構造的に」Animal型のサブタイプだからです。
Animal型と同様、Vehicle型には public speed: number run():=>stringのプロパティが含まれていますよね。
また、Animal型を期待している rideOn 関数では、Animal型の Animal.prototype.runを実行しているわけですが、Vehicle型の Vehicle.prototype.runは Animal.prototype.runのサブタイプになっていて返り値も同じ。
中で呼び出しても全く問題ありません!
じゃあ、「Vehicle型ってほぼAnimal型なのでは…?」というのがTypeScriptの主張です。
実際、つじつまはあっています。
このように、TypeScriptで採用している「構造的部分型」は、クラスの継承関係などには関係なく、オブジェクトの持っている構造によって割り当て可能性を判定するロジックになっています(※1)。
こういうのを、「ダックタイピング」といいますね。
“If it walks like a duck and quacks like a duck, it must be a duck”(もしもそれがアヒルのように歩き、アヒルのように鳴くのなら、それはアヒルに違いない)
出典: Wikipedia
ちなみに、同じく構造的部分型を採用している言語として Go が挙げられます。
以下のGoコードを見てみましょう。やっていることはだいたい同じです。
しかし、TypeScriptの場合と異なり、Animal構造体を期待している24行目の rideOn 関数はエラーを起こします。
cannot use vehicle (variable of type Vehicle) as type *Animal in argument to rideOn
この場合、 rideOn 関数内で実行している runメソッドを持っている型を CanRun interfaceとして切りだしてあげます。
それに合わせて、28行目のように引数の型も CanRun に変更してあげると、期待通りに動作します。
「え、interfaceで型制約をかけるのであればJavaとかと同じでは、、、?」と思った方もいるかもしれないですね 🧐
たしかにそうですが、GoではJavaなどとはinterfaceの使い方が若干異なります。
具体的には、Goでは定義したinterfaceを構造体に明示的には実装しません。
Javaではimplementキーワードを使用してクラスに対して明示的に実装する必要がありますが、Goではinterfaceは勝手に推論されて勝手に実装されます。
(Javaなどでのコード例比較があるとよい)
Goでは、構造体の持つメソッドからinterfaceが推論されて暗黙的に実装されます。
結果として、構造体の持つ構造をもとにして型の割当可能性を探っていることになっているのですね。
TypeScriptにおけるサブタイプについて
TypeScriptの型の階層構造
TypeScriptの型は以下のような階層構造として表現されます。
ざっくりと、上の方にあると「スーパータイプ」下の方にあると「サブタイプ」と思っていただければ問題ないです。
階層構造の上下に、JavaScriptでは見慣れない型が存在することがわかります。
それぞれ見ていきましょう。
unknown型
unknown型は「階層構造のもっとも上位に位置する型」です。
後述のany型と非常に似ていますが、any型とは異なり値のプロパティへのアクセスを拒否します。
型が何なのかわからないのに、プロパティにアクセスできてしまったら静的型付けの意味がないですね。
型の割当が不明な値についてはアクセスさせないことで、コードにおける型の安全性を保っているということになります。
any型
any型は「TypeScriptで扱われている(unknown型を除く)すべての型のスーパータイプ」なので「(unknown型を除く)すべての型が割り当て可能」です。
オブジェクトがany型場合、通常のJavaScript同様、オブジェクトのプロパティの有無に関係なくアクセスすることができてしまいます。
結果として、静的型付けを使っていながらも十分な検証が行われないコードが生まれるので、なるべくならば使用を避ける方が賢明です。
never型
never型はany型の対極にある型です。
「TypeScriptで扱われているあらゆるすべての型のサブタイプ」なので「どんな型であっても割り当てることができない」という性質を持ちます。
「どんな型も割り当てられないのでは、利用場面がないのでは??」と思われるかもしれませんが、ちょこちょこ使うことになるので、挙動を覚えておくとよいでしょう。
具体例でみるTypeScriptの型割当可能性とサブタイプ
「String型」と「文字列リテラル型」
文字列全般を指すString型に対し、「リテラル型」はプリミティヴな文字列そのものを表します。
当然、特定の文字列はString型の持つ構造やメソッドをそのまま保持しているので、「リテラル型」 < 「String型」の関係が成り立ちます。
object型 Intersection型(交差型) Union型(共用型)
TypeScriptでは、オブジェクトに対して独自定義の型を割り当てることが良く行われます。
オブジェクトAがオブジェクトBのサブタイプであるかどうかは、「オブジェクトAの全プロパティについて、オブジェクトBの対応する各プロパティのサブタイプである」かによって判定されます。
また、TypeScriptでは「既存の2つの型を組み合わせた新しい型」(Intersection型)を作成するのはよくあることなのですが、その場合も、生成された交差型の判定は上記と同様に行われます。
Union型は、「複数の取りうる型を型の合併」として定義する型です。
とりうる型の合併なので、それに対する部分集合となるUnion型がサブタイプとして判定されます。
上記の例では、 Job型には ‘engineer’ ‘teacher’ ‘student’などの文字列リテラル型が割り当てられるほか、 ‘engineer’ | ‘teacher’ のようなJob型の部分集合となる型も割当可能です。
関数
関数の割り当て可能性については、少しひねりが必要になります。
以下の関数割り当てを例にとってみます。
Animal → Kitten → Cat という継承関係がある3つのクラスと、それぞれのクラスのインスタンスを引数に取り、別のクラスのインスタンスを返す関数を定義しています。
(実装してあるメソッドがわかりやすいよう全部オーバーライドしています。)
makeKittenRun 関数では内部で Kittenインスタンスを作成し、それを grow 関数に引数として渡しています。
また、grow関数の返り値は Kitten クラスのインスタンスを期待しているため、 Kitten.prototype.runを実行して関数は終了しています。
上記のコードで、TypeScriptがエラーするのは後半の3つだけです。
いずれも、「引数: 期待している型のサブタイプ 、返り値:期待している型のスーパータイプ」となっているのに気づきましたか。
コードを見れば明白なのですが、 この場合、grow関数の引数についてはKittenより狭い型制約がかかっていると、Kittenクラスのインスタンスを渡せません。
反対に、返り値については、少なくともKittenクラスに実装している Kitten.prototype.runと同等の関数を実行できなければいけないこともわかります。
つまり、関数Aに対し関数Bを割り当てたいと思った場合、「関数Aと関数Bについて、引数については関数Bは関数Aの引数のスーパータイプである必要があり、返り値については関数Bは関数Aのサブタイプである必要」があります。
ちょっとややこしいですね 😇
extends キーワード
TypeScriptにおいてextendsは以下のような3つのコンテキストで利用されます。
(詳しく調べてはいないので、もっとあるよって人は教えてください)
- class定義(継承)
- ジェネリクス
- コンディショナル型(+ inferによる型推論)
class定義におけるextendsは今更記載するまでもないかと思いますので、割愛いたします。
残る2, 3についてですが、こちらも特に難しいことがあるわけではありません。
大切なことは「サブタイプであるか否か」という視点を持つことです。
以下に、ジェネリクスとコンディショナル型を使った型 PickCommonの定義を例示します。
機能としては、「型Aと型Bを型引数に取り、両型の共通部分のみを切りだした新しい型を作る」というものとしています。
keyofキーワードはオブジェクトの各プロパティの直和型を取得することができます。
1行目の T extends object, U extends object はジェネリクスとして使用されています。
よって、T, Uいずれもobjectのサブタイプでなければ型引数として指定できません。
2行目の K extends keyof U は「型Kが型Uのプロパティの直和型のサブタイプか否か」を検証しています。
これは、上記のジェネリクスでは「型Uに型Tのキーが含まれていること」が制約により保証されていないために行っている検証です。
3, 4行目の T[K] extends U[K], U[K] extends T[K] は「値T[K]が値U[K]が相互にサブタイプの関係にあるか」を検証しています。
ここまでしてようやく、二つのオブジェクトから共通の型を持つキーのみをピックアップすることができます。(利用場面については特に考慮していません。)
まとめ
- TypeScriptの型割当は「構造的部分型」
- TypeScriptの型になれるには「サブタイプ」を極めろ!
今回はTypeScriptの学習で個人的に理解に時間がかかったところをピックアップしました。
実際、業務で使い始めたのは割と最近のことなのでまだまだ学習中の身ですが、いろいろな型を柔軟に定義できるのは面白いなーと思います。
この記事を読んで、少しでも型割当に対する理解が深まるとうれしいです 🧐
※1 ちなみに、「クラスの継承関係によって判定を行いたい、、」というケースでは、publicになっているプロパティをprivateにしてみましょう。
TypeScriptがエラーを出してくれるようになります。最近知りました。