TypeScriptはJavaScriptに静的型を導入したプログラミング言語で、登場から現在までその人気を増し続けています。
動的型付き言語であるJavaScriptに静的型の安全性(コンパイル時にバグ・間違いを発見することができる能力)を与えることで、TypeScriptはJavaScriptによる開発の効率を上げてくれます。
裏にJavaScriptがあるという特性もあり、TypeScriptは「部分的に静的型チェックをする」というような挙動をサポートしています1。詳しくは後述しますが、これによりJavaScriptからTypeScriptへの移行が可能となっています。TypeScriptは@ts-check
(あるいは@ts-ignore
)などを通じてこのようなユースケースも手厚くサポートしています。
このことの裏返しとして、TypeScriptを利用するときは注意すべき点があります。それは使い方によってはTypeScriptの能力が十分に発揮されず、安全性を享受できないという点です。記事タイトルにある敗北者のTypeScriptというのはもちろん筆者が造った言葉ですが、これはTypeScriptが持つ安全性を最大限享受できずに、それどころか逆に危険な状態でTypeScriptを使っていることを指します。
この記事ではどのようなときに敗北者のTypeScriptに陥ってしまうのか、そしてその対処法について議論します。
3行でまとめると
- TypeScriptで
--strict
を使わない人や、any
とかas
を濫用する人は敗北者です。 - これらを使っていいのはあなたがTypeScriptよりも賢くて真にそれが必要だと分かっている場合だけです。
- 「敗北者のTypeScript」だからといってTypeScriptのメリットが完全に無くなるわけではありません。JSからの移行段階での敗北は仕方がないし、「素のJavaScript」よりも「敗北者のTypeScript」のほうが安全です。どんどんTypeScriptを使って敗北していきましょう。
敗北者のTypeScriptとは何か
この記事でいう敗北者のTypeScriptとは、ひとことで言えば**any
やas
に代表される危険な機能を濫用するTypeScriptコード**を指します。あとで詳説しますが、これらの要素はTypeScriptが本来保証してくれる安全性を破壊する機能であり、使えば使うだけTypeScriptを使うメリットが減少してしまいます。
これは筆者の意見ですが、最も理想的な状況としてはTypeScriptのstrict
オプションを有効にするのはもちろん(これはnoImplicitAny
やstrictNullChecks
などの厳しいチェックを有効にします)、any
やas
などの危険な機能はeslint2のno-explicit-anyやconsistent-type-assertions3などのルールを用いて原則禁止としなければいけません。これらがどうしても必要な場合はいちいち// eslint-disable-next-line: no-explicit-any
のようなコメントを用いてルールを明示的に無効化する必要があり、さらにコメントでany
などが必要となる妥当な理由を説明していない場合はレビューで弾かれなければいけません。
TypeScriptを実際に使っている方の中には「そんな運用は現実的ではない」と思った方も多いでしょう。any
やas
という抜け道が封じられるということは、開発にかかる時間やその他のコストが増加することも考えられます。また、上記の運用をするには何がany
を使う“妥当”な理由であり何がそうでないのかを理解・判断する必要がありますが、そのためにはコードを書く人もレビューする人もTypeScriptに精通していなければいけません。そのような運用ができるチームを組むのは並大抵のことではないでしょう。
ゆえに、その通りです、TypeScriptユーザーの非常に多くはTypeScriptへの敗北に甘んじているのです。筆者ですら例外ではなく、業務ではany
を使用するコードに対して「」とだけコメントしてLGTMを出したこともあります。
なお、ここまで読んだ方は既に察しているとは思いますが、筆者は型の信奉者であり過激派であるという点はご理解ください。実際、同じことをeslint-typescriptのリポジトリに書いたら「(any
を禁止するのはいいけどas
を禁止するのは)俺だったらそんなん絶対嫌だわ(意訳)」と言われました。
ただ、思想はともあれ記事の内容は皆さんのTypeScriptコードをさらに安全にするのにきっと有用ですから、ぜひ読んでいってください。記事を読んで筆者と同じ思想に染まってくれたらさらに嬉しいです。
誰と誰が戦い、誰が負けたのか
ところで、ここまで読んだ方の中にはなにが敗北だ、俺は売られた喧嘩は買う主義なんだぜと思っている方もいるかもしれません。この記事のタイトルは、筆者の別記事**TypeScriptの型入門**におけるany
型の説明に由来します。
ここで、any型という言葉が出てきましたので、これについても解説します。any型は何でもありな型であり、プログラマの敗北です。
つまり、負けたのはany
を使ったプログラマです。そして、プログラマを打ち負かしたのはTypeScriptコンパイラです。敗北したプログラマはTypeScriptコンパイラと知恵比べを行いましたが、any
無しではコンパイルを通すことができませんでした。そこで、プログラマは妥協し、安全性を犠牲にしてコンパイルを通すという選択をしたのです。
any
は、プログラムの実態(=プログラムが持つバグ・誤り)を何も変えないままに多くのコンパイルエラーを消す力を持ちます。つまり、any
を使うというのはTypeScriptがせっかく発見してくれた問題を放置し、TypeScriptを黙らせることを意味します。これこそがプログラマの敗北であり、プログラマは敗北の代償としてプログラムの安全性を失ったのです。
敗北の理由には、実力が足りない、あるいは時間が足りないといったことが挙げられるでしょう。any
を使わなくてもコンパイルが通るようなプログラムの設計や書き方ができないか、あるいはそのような良い書き方をする時間が無い場合に敗北が発生しがちです。
ここではany
が槍玉に挙がりましたが、as
の場合も同様です。これらはTypeScriptの安全性を脅かす言語機能第1位と第2位であり、どちらも使うとプログラムの安全性が犠牲になります。どう危険なのかということはこの記事でじっくり説明しますから、ご安心ください。
TypeScriptが敗北するとき
ところが、TypeScriptのスキルが一定以上ある方は、any
やas
を全く使わずにTypeScriptプログラムを書くのは不可能であるということをご存知でしょう。TypeScriptコンパイラも完璧ではなく、プログラムのロジックを理解できなかった結果として、実際にはありえない型エラーを報告してくることがあります。any
やas
はそのような場合にコンパイルエラーを消す手段として有効です。
これはプログラマの勝利、言い換えればTypeScriptの敗北です。書かれたプログラムがTypeScriptの推論能力を上回った結果としてany
やas
の使用を余儀なくされることがあるのです。これが、冒頭で触れた「any
やas
がどうしても必要な場合」です。
TypeScriptの敗北は型変数やconditional typeなどの複雑な機能を使う場合に多く発生しがちです。例えば、筆者の別記事TypeScriptの型レベル連結リスト活用術:型を変えられるコンテナを作るでは、any
やas
がさりげなく使われているのを見ることができます。
any
はas
は使わないのが理想的ですが、実際のところそう上手くはいきません。プログラマの敗北かTypeScriptの敗北のいずれかの結果として、any
やas
は必要となります。この記事の主張は、any
やas
はTypeScriptの敗北の場合にのみ使うべきであるということです。それができなかった結果が、敗北者のTypeScriptです。
別の言い方をすれば、any
やas
をプログラマの力不足を埋め合わせるために使ったのならばそれはプログラマの敗北であり、TypeScriptの力不足を埋め合わせるために使ったのならばそれはTypeScriptの敗北です。any
やas
の使用が妥当かどうか見極めるには、どちらの力不足が原因なのかを見極める必要があります。ただし、これをきちんと見極めるのは並大抵のことではありません(それがなぜかは記事中で追々解説していきます。)。
前置きが長くなりましたが、いよいよ本題に入りましょう。まず、any
やas
の何が危険なのか解説していきます。これを理解することは、any
やas
を避けるべき理由を理解するだけでなく、プログラマの敗北とTypeScriptの敗北を見分けるためにも重要です。
any
の危険性
any
というのはTypeScriptが提供する特別な型です。any
型はTypeScriptを黙らせるための型であり、any
型に対して何をしてもTypeScriptは型エラーを出しません。
any
型が使われる場面は以下のように分類できます。
- 正しいコードを書いたのにTypeScriptの型推論能力が足りなくてエラーになるのでTypeScriptを黙らせたい場合(TypeScriptの敗北)
- 実際コードが間違っていてTypeScriptがエラーを吐いているけど直せないのでとりあえずエラーを消したい場合(プログラマの敗北)
- どんな型を書けばいいのか分からないのでとりあえず
any
と書いている場合(プログラマの敗北) - JavaScriptからの移行の途中でまだ型を書いていないのでとりあえず
any
にしている場合(移行途中なら仕方ないけど敗北は敗北)
最初のひとつ以外は望ましいany
の使用ではありません。また、後述しますが、最初のものも基本的には後述のas
で代用可能です。
具体的なany
型の特徴は、任意の型の値をany
型として扱うことができること、そして**any
型の値に対してどんな操作を行っても型エラーが起きない**上、その結果はany
型になることです。では例を見ましょう。
// 関数fooは適当なオブジェクトを受け取ることができるつもり
function foo(obj: any): number {
// obj.numには数値が入っているはずなのでobj.numが存在すればそれを返すつもり
if (obj.num != null) {
return obj.num;
}
// obj.numが存在しない場合もあるのでその場合は0を返す
return 0;
}
この例では、関数foo
の引数obj
の型をany
としたことでこの関数の型安全性が崩壊しています。その結果、次のようなコードで問題が発生するでしょう。
foo(null); // これは型エラーは発生しないが実行時エラーが発生する
// 変数valはTypeScript上ではnumber型なのに実際には"12345"という文字列が入る
const val = foo({ num: "12345" });
// 文字列にtoFixedメソッドは存在しないのでここで実行時エラーが発生する
console.log(val.toFixed(20));
このように、any
を使うと実際には危険なコードでも型エラーが起きません。まず、foo
の引数obj
はany
型なので、どんな値も受け入れます。その結果、型エラーを出すことなくfoo
に引数として{ num: "12345" }
を渡したりnull
を渡したりできます。
関数foo
の中ではまずobj.num
というアクセスが発生しますが、obj
はany
型なのでもちろんエラーは発生しません。また、obj
はany
型なのでobj.num
もany
型として扱われます。
ここでany
の危険性がひとつ露呈していますね。obj
にnull
とかundefined
が入った場合はobj.num
というプロパティアクセスは実行時エラーとなりますが、TypeScriptはそれを型エラーとして検出してくれませんでした。これはobj
がany
型だからです。
さらに、return obj.num;
にも問題があります。関数foo
の宣言を見ると戻り値はnumber
型として宣言されていますのでobj.num
はnumber
型でなければなりませんが、obj.num
はany
型なのでそれをnumber
型として扱っても型エラーは起きません。明らかに、実際obj.num
に数値が入っている保証がありませんので、これは実行時のエラーに繋がります。
このように、any
型を使うとTypeScriptは本来防げたはずの実行時エラーを防いでくれなくなります。これではTypeScriptを利用するメリットをプログラマ自ら殺していることになりますから、TypeScriptが提供する安全性を最大限享受するためにはany
を使うべきではありません。
特に、型をちゃんと書くのが面倒くさいからany
にするというケースは最悪のパターンです。書こうと思えば書けた型を敢えて書かないというのは、(時間的要因など仕方ない部分があるかもしれませんが)明らかにプログラマの敗北です。
実際のところ、any
を使っているコードでも問題なく動作するという場合も多いでしょう。そもそも人類は型のないJavaScriptで沢山のプログラムを書いてきたのですから、any
の存在下で実際正しく動くプログラムを書くこともできるでしょう。そのようなコードを書いている方ならば、こんなのちょっと考えれば安全だって分かるんだからany
を使ってもいいだろと思うかもしれません。しかし、筆者はそのような考え方は推奨しません。なぜなら、any
はコードの負債になるからです。
実際安全なコードであったとしても、any
とコードに書くということはそのコードが危険であると積極的に主張しているのも同然ですから、コードに書かれたそのany
はこれからそのコードを見るすべての人を警戒させ、思考時間を奪います。any
と書かなければTypeScriptが安全性を保証してくれたのに、any
と書いてしまったばかりにTypeScriptは型チェックを放棄し、そのコードの安全性を保証するのは人間の責任となるのです。これこそがまさに、TypeScriptを使う意味が薄れるということです。
このany
がいかに安全かをコメントに書き残しておくという手もありますが、それならany
を消してちゃんと型を書いたほうがよいです。any
の安全性を証明するにはそのany
が関わるロジック全てを完全に精査し、コメントを読むだけで納得できるように分かりやすく説明しなければいけませんが、その労力はちゃんと型を書くために使うべきです。
もしどうしても正しい型が書けないとき、ほら見ろTypeScriptの敗北だと思われるかもしれませんが、それは少し早計です。プログラムの設計変更によってよりロジックを単純化することで、型が書けるようになる可能性があるからです。TypeScript時代においては、ちゃんと型で表現できるようにプログラムを設計するということもTypeScriptのスキルの一つですね。
そもそも型をどう書けばいいか分からないようなプログラムは、ロジックが複雑すぎます。TypeScriptがどうとか以前に、そのようなプログラムは書くべきではありません。人間がロジックを理解できないということは、たとえテストを書いても意味がなく、安全性を保証するのがたいへん困難だからです。
どうすればよかったのか
では、先ほどのany
の例に話を戻して、これを正しく直してみましょう。いきなり正解を述べると、この場合はobj
の型をany
ではなく{ num?: number }
とすべきです。この型の意味は、「オブジェクトであり、プロパティnum
を持っていても持っていなくてもいいが、持っている場合はプロパティnum
はnumber
型でないといけない」という意味です4。この型を用いることで、関数foo
に変な値が渡されて実行時エラーとなるのを防止できます。
function foo(obj: { num?: number }): number {
if (obj.num != null) {
return obj.num;
}
return 0;
}
// ↓これはコンパイルエラーになる
foo(null);
// ↓これもコンパイルエラーになる
const val = foo({ num: "12345" });
この例では、obj
の型にany
を使うのをやめたことで実行時エラーに繋がるバグ(foo(null)
とか)を検出することができました。つまり、TypeScriptが提供する安全性を正しく活用できたことになります。
繰り返しますが、obj
に与えるべき型が分からずとりあえずany
にしていたとしたら、それはプログラマの敗北としか言えませんね。正解が分からなかった方は、敗北者から抜け出すためにもっとTypeScriptの型を勉強するのが吉です。たとえば以下の記事などはその助けとなるでしょう(宣伝)。
もっと言えば、型が書けないときにTypeScriptの敗北であると主張するためには、TypeScriptでできることを全て知り尽くしていなければその資格がないということです。TypeScriptに勝利できるのはTypeScriptマスターだけなのです。
--noImplicitAny
を使わない場合の危険性
もうひとつany
に関連する話題として、--noImplicitAny
の話をしましょう。これはTypeScriptのコンパイラオプションの一つで、tsc
のコマンドラインオプションかtsconfig.json
を通じて指定します。
このオプションは暗黙にany
型を推論するのを避けるというオプションであり、もちろん利用すべきです。逆に、このオプションを使用しない場合は前述の危険なany
型が暗黙のうちに発生してしまうことがあり、非常に危険です。
まず、TypeScriptでは型宣言を省略することができる機能があります。これは、何も型が書いていないただのJavaScriptコードも受け付けられるようにしてJavaScriptからTypeScriptへの移行を支援するという目的があります。
// 関数fooの引数numの型と返り値の型が省略されている
function foo(num) {
return num.toFixed(3);
}
console.log(foo(123)); // "123.000"
では、上記のコードでnum
の型はどうなるでしょうか。我々の感覚だと、foo(123)
という呼び出しがあるのでnum
の型はnumber
になってほしいですね。ところが、実際には**num
の型はany
となります**。すなわち、関数引数の型が宣言されていない場合は問答無用でany
になるのです5。この挙動は、JavaScriptからTypeScriptへ移行途中でまだ型が書けていない場合には便利です。まだ書かれていない変数の型をとりあえずany
としておくことで、まだ型を書いていない部分で型エラーが発生するのを抑止してくれるのですから。
とはいえ、これはあくまで移行途中だから許される話であり、安全性を求めるならこのような挙動は言語道断です。そこで、このように**any
と書いていないのにany
型が発生する**事態を防いでくれるのが--noImplicitAny
オプションなのです。このオプションを有効にすると、上述のように引数の型が宣言されていない場合は問答無用でエラーになります。TypeScriptは関数のインターフェースは明示すべきという方針を採用しているように思われますから、関数の引数の型はちゃんと明示し、--noImplicitAny
オプションがオンの状態でもエラーが発生しないようにするのが正しいTypeScriptコードのあり方です6。
また、引数の型が書かれていないケース以外でもいくつかの場合(代表的なのは型定義の無いライブラリを読み込んだ場合)にany
が発生することがあり、--noImplicitAny
はそのような挙動を全て防止してくれます。
--noImplicitAny
は安全にTypeScriptを使うなら絶対に使うべきオプションですが、一つでも型の書いていない引数があればコンパイルエラーとなり、型定義が用意されていないライブラリを使うのもコンパイルエラーになるため、このオプションはJavaScriptからTypeScriptに移行している途中では使えないという特徴を持ちます。それゆえ、一部には「--noImplicitAny
を無理に使わなくてもいい」という主張をする人もいます。そのような考え方を否定はしませんが、それはany
の自然発生という大敗北を受け入れるという、もはや不戦敗とも言えるやり方です。
筆者が危惧するのは、そのような負け犬根性が染み付いてしまい、それがTypeScriptの唯一のあり方であると思われることです。そのような主張が妥当性を持つのはあくまでJavaScriptからTypeScriptへの移行の途中の話であり、TypeScriptに移行が完了した後も**--noImplicitAny
オプションありでコンパイルが通らないようなコード**は、敗北者のTypeScriptであることを免れません。
そろそろ顔が赤く染まってきた読者も多いと思うので再度述べておきますが、敗北者のTypeScriptを書くなと言っているわけではありません。それでもTypeScriptを使わないよりはずっと安全です。
ただ、ここでお伝えしたいのは、--noImplicitAny
を使わなくてもいいという主張は「--noImplicitAny
を使わないで達成できる程度の安全性で満足できるなら」という枕詞が付きます。any
が自然発生する状況では、例えば関数の引数の型を書き忘れるという単純なミスが、単なるコンパイルエラーでは済まずにランタイムエラー、壊れたロジック、その他重大なバグに繋がる可能性があります。--noImplicitAny
を使わないというのは、このように本来TypeScriptがコンパイルエラーという形で保証してくれる安全性を放棄するという明示的な意思表示に他なりません7。TypeScriptの安全性をどの程度享受し、そしてどの程度捨てるのかはプロジェクトが選択できることであり、TypeScriptの使われ方というのはその選択によって大きく異なったものとなります8。
ですから、--noImplitiyAny
は使わなくてもいいという主張を常に真に受けるのではなく、自分たちがどの程度の安全性を達成したいのかと天秤にかけて判断する必要があるのです。そして、自分たちの選択が自分たちにどの程度の安全性を齎し、また自分たちはどれくらい安全性を犠牲にしているのか、それを判断するのにこの記事は役に立つことでしょう。
この記事では以降も「〜すべき」などの言い回しを積極的に使いますが、それは「敗北者にならないためには」という前提の話です。ゆえに、あなたがどのレベルの安全性を求めるかによってはこの記事の主張は当てはまらないかもしれません。しかし、TypeScriptの真の力を知っていて敢えて使わないのか、それともそもそも真の力を知らないで使っているのかでは大違いです。では、次に進みましょう。
--strictNullChecks
を使わない場合の危険性
TypeScriptの他の代表的なコンパイルオプションとしては--strictNullChecks
が挙げられます。これはnull
安全性(JavaScriptにはnull
の他にundefined
もあるのでそちらも)に関わるチェックを有効にするオプションです。
逆の言い方をすれば、このオプションを指定しないとTypeScriptのnull
安全性が無に帰します。こんなのオプションにしないで常にnull
安全にしろよとお思いだと思いますが、実は初期のTypeScriptにはnull
安全性が無く、後から追加で実装されたためこのような形になっています。それまでTypeScriptでコンパイルが通ってきた非null
安全なコードのビルドを壊さないためにデフォルトでオンにするのは避けられたのです。例えば、--strictNullChecks
をオンにしないとこんなコードのコンパイルが通ります。
// fooは数値を受け取って文字列を返す関数
function foo(num: number) {
return num.toFixed(3);
}
// fooにnullを渡すことができる!!!!!
foo(null);
このように、どんな型に対してもnull
(とかundefined
)を渡すことができたのです。
--strictNullChecks
オプションありならば、null
はnumber
型の値だと見なされなくなるのでこのコードはコンパイルエラーが発生します。嬉しいですね。
--strictNullChecks
ありの状況では、null
を扱う場面では明示的に**null
型**を使用します。例えば、上記のコードをfoo
がnull
も受け取れるようにするには次のようにします。
// fooは数値またはnullを受け取って文字列を返す関数
function foo(num: number | null) {
if (num == null) {
// numがnullなら空文字列を返す
return "";
}
// そうでなければnumはnumber型なので従来通り
return num.toFixed(3);
}
// どちらもエラーが起きない
foo(null);
foo(123);
引数num
の型をnumber | null
としたことによってnum
が数値とnull
のどちらも受け取れるということを明示しました。また、コード中でif (num == null)
というnull
チェックを行うことでnull
の処理を行います。TypeScriptはこのような処理を検知して、num
がいつnull
でありいつnull
でないかを推論してくれます。その結果として、if文の次のnum.toFixed(3)
の部分ではnum
がnull
である可能性は既に排除されていると判断されてnum
はnumber
型となり、コンパイルエラーにならずにtoFixed
を呼び出すことができています。
このnumber | null
は「number
型またはnull
型」ということを表すunion型の記法です。TypeScript以外ではあまり見ないため戸惑う方もいるかもしれませんが、安全なTypeScriptプログラミングにおいては頻出の機能です。このように、TypeScriptの型の知識というのは安全にプログラムを書く能力に直結します。型の知識を付けるためには、例えば以下の記事がおすすめです(宣伝)。
その他のコンパイルオプションたち
TypeScriptの安全性は日進月歩で改善されており、時折新しい安全性チェックが導入されます。--strictNullChecks
と同様、それらは後方互換性を崩さないために原則としてコンパイルオプションの形で導入されます。その結果、これまで紹介した2つ以外にも安全性のチェックを強化するコンパイルオプションがいくつも提供しています。これらを有効にしないということはそれだけTypeScriptの安全性に穴を開けることになりますから、それらのコンパイルオプションは当然全て有効にすべきです。
そこで、これらのコンパイルオプション(前述の2つも含めて)をまとめて有効化できる素晴らしいオプションがあります。それが--strict
です。ということは、--strict
オプションを有効化することが敗北者からの脱却の前提条件です。TypeScript側としても--strict
オプションの利用を推奨しており、tsc --init
によって生成されるデフォルトのtsconfig.json
ファイルは--strict
オプションが有効化された状態となっています。
前述のような事情から、JavaScriptからTypeScriptへの移行案件の場合は--strict
オプションをいきなり有効化するのは難しいでしょう。しかし、新規のTypeScript開発で--strict
を有効にしないという選択はまず有り得ず、あったとすればそれは前述の負け犬根性の表れであると言わざるを得ません。敗北者のTypeScriptにすでに毒されています。コードを書かないことがバグを生まない最善の方法であることは広く知られていますが、--strict
を無効化するというのはまっさらの安全性100%の状態からコードを書き始める前に安全性を50%くらいまで落とすという行為に他ならないのです。
as
の危険性
as
はTypeScriptに特有の、型アサーションを行うための構文です。これは、型情報を強制的に修正するときに使います。まずは例を見てみましょう。
type MyObj1 = {
id: number;
}
type MyObj2 = {
id: number;
name: string;
}
function foo(obj: MyObj1): string {
// ↓これはエラー
// return obj.name;
// objはMyObj1型だけど無理やりMyObj2型に修正して利用
return (obj as MyObj2).name;
}
関数foo
の中でas
が使われていますね。関数foo
はobj.name
を返したいのですが、obj
はMyObj1
型でありname
プロパティが存在しないのでこれは型エラーとなります。そこで、エラーを回避するために一時的にobj
の型をMyObj2
型にしてしまうのが(obj as MyObj2)
です。これによりobj
はMyObj2
型となり、name
プロパティを持っていると見なされるので型エラーが消えます。
これのどこが危険かはお分かりですね。foo({ id: 123 })
のように実際はname
プロパティを持たないオブジェクトをfoo
に渡すと返り値はundefined
です。これは、foo
の返り値の型がstring
であることと合致していませんので、型情報と実行時の挙動が異なることになり実行時エラーに繋がります。
このように、as
はTypeScriptに文句を言わせずに型に対して危険な修正を行うことができる(具体的には型をその部分型に修正できる)機能です。ですから、正当な理由無く使うべきではありません。
まあ実際のところ、any
は絶対だめだけどas
はまあ使ってもいいんじゃないという意見も見られます。それは、TypeScriptの敗北が発生したときにas
を使って対応することが多いからです。というのも、TypeScriptの推論能力が足りないせいでコードに型エラーが発生する場合には、型が正しく推論されていない(実際のプログラムの動きと異なる・推論された型が実際に保証される条件よりも弱い)場合がほとんどです。この際に、as
を用いて正しい型に修正することで型エラーをなくすことができます。
逆に、型は正しいのに、その使い方が間違っているために型エラーが出るということもあります。上の例はこちらのパターンです。この場合、型を間違ったものに修正してコンパイルエラーを消すためにas
を使うことができます。これは言うまでもなくプログラマの敗北です。
つまり、as
を使いたい場合、それがどちらの敗北なのかを見極めるべきです。TypeScriptの敗北ならばas
を使ってもよいですが、自分の敗北の場合にas
を使うべきではありません。当然ながら、as
を使うためには、どちらの敗北か見極められるだけのTypeScript力が求められるということです。
ちなみに、as
は実行時には何も行いません。つまり、他の言語でいうキャストとは異なり、まずい型変換が行われたら実行時にエラーが出るわけではありません。ただ型チェック時に型が修正されるだけです。なので、型変換がまずいかもしれないので実行時にチェックしたいという場合は別途そのコードを書く必要があります。
as
を使ってもよい例
基本的に、as
を使っていいのはあなたの書いたコードがTypeScriptの推論能力を凌駕したときです。ここではその一例を紹介します。
よくあるのは、オブジェクトをミュータブルなものとして扱う場面です。与えられたオブジェクトをコピーする関数を考えましょう。
function shallowCopy(obj) {
const result = {};
for (const key in obj) {
result[key] = obj[key];
}
return result;
}
素朴にTypeScriptで型をつけようとするとこうなるでしょう。
function shallowCopy<T extends object>(obj: T): T {
const result = {};
for (const key in obj) {
result[key] = obj[key];
}
return result;
}
しかし、これはエラーとなります。その理由は、const result = {}
によってresult
の型が{}
と推論されるからです。これにより、result[key] = obj[key]
はresult
に対して未知のプロパティを勝手に足そうとしているのでエラーになります。また、return result;
は戻り値がT
型なのに{}
型の値を返そうとしているとしてエラーになります。
TypeScript側の主張も一理ありますね。実際、const result = {};
の瞬間にresult
に入っているのは{}
でありresult
はプロパティを何も持っていませんからこれの型は{}
とせざるを得ません。
問題は、これを書いた人はこの関数内でresult
オブジェクトのプロパティがどんどん作られて最終的にT
になることを意図しているのに、TypeScriptがそれを理解してくれないことです。実際のところ、コンパイラがこれを理解するのはかなり難しいでしょう。そこで、この場合はas
を使ってエラーを消さなければなりません。
一番簡単なのは次のようにする方法です。
function shallowCopy<T extends object>(obj: T): T {
const result = {} as T; // {} を {} as T に変更
for (const key in obj) {
result[key] = obj[key];
}
return result;
}
as
を使うことで、{}
の型を{}
型ではなくT
型だと思わせました。これにより変数result
の型もT
型になります。さっきの話からするとこれは嘘であり(変数result
が作られた瞬間は{}
がT
型を満たすとは限らないため)、このコードはas
によって型システムを欺く危険なコードとなっています。
この場合、as
による危険性がshallowCopy
の中に閉じ込められているのがポイントです。shallowCopy
の内部でやっていることは危険ですが、shallowCopy
のインターフェース(T
型の引数を受け取ってT
型の値を返す)は正しいことは人間が注意深くチェックすれば確かめることができます。よって、shallowCopy
を使う側は内部のas
による危険性を気にせずにこの関数を使うことができます。
まとめると、as
を正しく利用するためには以下のことに注意する必要があります。
- TypeScriptの敗北を明らかにする。この場合はTypeScriptが手続き的な操作でオブジェクトを作るコードを正しく型推論してくれないのが問題でした(やるのはかなり難しいでしょうから仕方ありませんが)。
-
危険性の影響範囲をできるだけ小さい関数に閉じ込める。上の例で見たように、正しいインターフェースを持つ関数を定義し、
as
の危険性をその内部でしか露呈しないようにしましょう。これにより、危険性が影響する範囲を明確にして人間による安全性のチェックの負担を減らすことができます。また、危険なas
を使う目的が明確になります。
!
の危険性
TypeScriptに独自の構文として後置の!
が知られています。これは値がnull
やundefined
かもしれない可能性を無視するという構文です。とりあえず例を見てください。
type MyObj = {
name?: string;
}
function foo(obj: MyObj): string {
// obj.nameの最初5文字を返す(存在しない可能性は無視)
return obj.name!.slice(0, 5);
}
obj.name!
というのはobj.name
に後置の!
がついた構文です。これにより、obj.name
が存在しない可能性を無視しているのです。
というのも、MyObj
型の宣言を見れば分かるとおり、obj
のname
プロパティは存在しないかもしれません。これにより、obj.name
はstring | undefined
型(string
型かもしれないしundefined
型かもしれない値の型)となります。undefined
に対してslice
メソッドを呼んだら実行時エラーになってしまいますから、それを防ぐために当然TypeScriptは型エラーを出します。
ここで、いやそんなの気にしないでいいからエラー出すなよと思った場合にobj.name!
とすることで、これがundefined
である可能性が排除されてstring
型として扱われます。それにより、型エラーを発生させずにslice
を呼ぶことができるのです。
ちなみに!
はas
を使っても代用可能です。as
を使う場合は次のように書けます。これはobj.name
の型をstring | undefined
からstring
に修正するということですね。
type MyObj = {
name?: string;
}
function foo(obj: MyObj): string {
return (obj.name as string).slice(0, 5);
}
ということで、!
の危険性は基本的にas
のそれと同じです。!
をいつ使えばいいかとかそういう話も、基本的にas
と同じことが当てはまります。
一応言っておくと、上記の例は明らかに!
の妥当な使い方ではありません。foo
に渡されるobj
が必ずname
を持っていることが分かっているならばMyObj
を修正すべきですし、name
を持っていないかもしれないならfoo
の中の処理を修正すべきです。
!
を使ってもよい例
基本的に!
に対する考え方はas
と同じですが、わりと!
を使いそうな妥当な場面というのはあります。
type MyObj = {
name?: string;
}
function foo(obj1: MyObj, obj2: MyObj): string {
if (obj1.name == null && obj2.name == null) {
// 両方ともnameを持っていない場合の処理
return "";
}
// (中略)
if (obj1.name != null) {
// obj1がnameを持っていた場合の処理
return obj1.name;
} else {
// obj2がnameを持っていた場合の処理
return obj2.name; // ←ここで型エラーが発生!
}
}
関数foo
は2つのMyObj
型のオブジェクトを受け取り、それらが両方ともname
を持っていなかったときの処理を最初に行います。ということは、foo
の残りの部分では「obj1.name
とobj2.name
の少なくとも一方は存在する」という条件が満たされることになりますが、TypeScriptはこのような「どちらか一方が成り立つけどどちらなのかは不明」というような条件を理解できません9。それにより、if
文のelse
部でエラーとなっています。人間ならば、この位置ではobj1.name
が存在しないのでobj2.name
が存在するであろうことが理解できますが、TypeScriptはこれが理解できません。よって、この場合はreturn obj2.name!;
としてもまあ許されるでしょう。
なお、一応次のようにすることで!
の使用を回避することはできます。
if (obj1.name == null && obj2.name == null) {
// 両方ともnameを持っていない場合の処理
return "";
}
// (中略)
if (obj1.name != null) {
// obj1がnameを持っていた場合の処理
return obj1.name;
} else if (obj2.name != null) {
// obj2がnameを持っていた場合の処理
return obj2.name; // ←エラーが起こらない
} else {
// ここは絶対に通らないけどTypeScriptのために仕方なく書いた部分
return "";
}
これなら!
を使わずにエラーが回避できますが、代わりに絶対に使われない無駄なコードが発生してしまいました。カバレッジが無駄に下がるといった問題点もありますからどちらも一長一短でしょう。
その他の危険性
ここまでで紹介したany
, as
, !
くらいがTypeScriptが持つ代表的な危険性です。言うまでもなく、これらはTypeScriptの安全性の限界とかではなく、我々を甘やかし負け犬奴隷とするためにTypeScriptが用意した穴です。我々は厳しい自制心でもって、これらの利用を最低限に留める必要があります。
しかしながら、TypeScriptが持つ危険性というのはこれだけではありません。これまで見てきたような甘ったるいものではなく、単なる罠としか思えないものもあります。基本的にはこれらの危険性も、利便性と安全性のトレードオフにおいてTypeScriptの言語デザインとして利便性のほうを選択したことで起こっています。利便性のために存在している危険性であるということは、つい危険な要素を使ってしまいがちであるということです、それら全てに目を光らせるのは難易度が高いですが、できるだけこれを防ぐためにぜひ知っておくべきです。
これについては以下の記事によくまとまっています(筆者が書いた記事ではありません)。
is
の活用
TypeScriptが提供する危険性のひとつがis
です。is
自体も正しく使わないとTypeScriptの安全性を破壊してしまう危険性を持ちますが、むしろis
は前述のように「危険性の影響範囲を小さくする」という目的で活用することができる諸刃の剣です。TypeScriptに勝利した機会にis
を活用して危険性を最小に押し留めることができれば、勝利者の称号はあなたのものです。
では、まずis
がどういう意味なのか見てみましょう。これは関数の返り値の型として使うことができる特殊な構文で、引数名 is 型
という構文を持ちます。
function isStringArray(obj: unknown): obj is Array<string> {
return Array.isArray(obj) && obj.every(value => typeof value === "string");
}
function foo(obj: unknown) {
if (isStringArray(obj)) {
// この中ではobjはArray<string>型になる
obj.push("abcde");
}
}
この例ではisStringArray
の返り値の型がobj is Array<string>
です。このような型宣言を持つ関数は、返り値が真偽値でなければなりません。そしてこれは、返り値がtrue
のときobj
の型はArray<string>
とみなして良いという宣言になります。
よって、関数foo
の中のif
文でisStringArray(obj)
が真かどうか判断したことによって、その中ではobj
をArray<string>
型の変数として使うことができるのです。
ちなみに、ここで登場しているunknown
という型は「どんな値でもよい」ことを表す型で、一見any
と似ていますがany
のような危険性を持たず安全に使える型です。引数の型をunknown
型にするということは、どんな引数でも受け付ける(オブジェクトだろうとnull
だろうと何でも)ということを意味します。ですから、そのような引数を使いたい場合はif
文などで型の絞り込みを行わければいけません。そのような場合にis
が役に立ちます。
is
の危険性は、その関数が誤った判断を行えば間違った型がついてしまう点にあります。上記の関数を次のように変えるとisStringArray
関数は嘘をついていることになりますが、TypeScriptは怒ってくれません。is
を使う関数の中身は危険性の塊であり、嘘をつかないことはプログラマの責任なのです。
function isStringArray(obj: unknown): obj is Array<string> {
return Array.isArray(obj) && obj.every(value => typeof value === "number");
}
余談: unknown
とas
実は、ここで出てきたunknown
とas
を併用することでany
の必要性をほぼ消すことができます。
as
は型を強制的に変えますが、全く無関係の型に変えることはできません。例えば"foo" as number
のように文字列をそれとは無関係の数値型に変えることはできないのです(ここで無関係というのは、どちらの方向の部分型関係にもないことを意味します)。ただ、unknown
を間に挟んで"foo" as unknown as number
とすれば通ります。unknown
を経由することで型を任意に変える(繰り返しますが、ランタイムには何も起こりません。あくまでTypeScriptを騙すだけです)ことができるのです。これができればだいたいany
でやりたいことを達成できるため、実はas
を優先して使うようにすれば本当にany
を使う場面というのはほぼありません。
is
の活用例
では、is
に話を戻してもうちょっと実際にありそうな活用例をご紹介します。
type MyObj = {
name?: string;
}
// 渡されたオブジェクトたちの`name`プロパティの配列を返す。
// ただし`name`が存在しないものは抜かす。
function allNames(objs: Array<MyObj>): Array<string> {
return objs.map(obj => obj.name).filter(name => name != null);
}
// ["Tanaka", "Yamada"] と表示される
console.log(allNames([{name: "Tanaka"}, {}, {name: "Yamada"}]));
このコードは型エラーとなります。なぜなら、objs.map(obj => obj.name).filter(name => name != null)
の型がArray<string>
ではなくArray<string | undefined>
と推論されるからです。まずobjs.map(obj ==> obj.name)
の型はArray<string | undefined>
となります。これは想定通りですね。
次に.filter(name => name != null)
でundefined
を弾いているのだから、その結果はArray<string>
となってくれるのが嬉しいですが、filter
はそこまで賢い推論をしてくれません。
この問題に対するひとつの対処法はas
を使うことです。次のように、as
を使ってArray<string | undefined>
をArray<string>
に強制的に修正するとエラーが消えます。
function allNames(objs: Array<MyObj>): Array<string> {
return objs.map(obj => obj.name).filter(name => name != null) as Array<string>;
}
この方法の残念な点は、as
の危険性がallNames
全体に広がっている点です。実は、ここでis
を使うともっと危険性を狭い範囲に押し込めることができます。そのためには次のようにします。
// 引数がnullでもundefinedでもないことをチェックする
function isNotNull<T>(x: T): x is Exclude<T, null | undefined> {
return x != null;
}
// 渡されたオブジェクトたちの`name`プロパティの配列を返す。
// ただし`name`が存在しないものは抜かす。
function allNames(objs: Array<MyObj>): Array<string> {
return objs.map(obj => obj.name).filter(isNotNull);
}
新しい関数isNotNull
はis
型を使用しています。これは、返り値がtrue
ならx
がExclude<T, null | undefined>
型であるという意味です。T
というのは引数x
の型であり、Exclude<T, null | undefined>
というのはざっくり言えば「T
からnull
とundefined
の可能性を排除した型」です。例えばT
がstring | undefined
の場合はExclude<T, null | undefined>
はstring
になります。
これを使う側は.filter(isNotNull)
となりました。なんと、これでエラーが消えてしまいましたね。実は、filter
は返り値がis
型の関数を渡すとそれを理解して返り値の型を調整してくれるように定義されています。
今回のポイントは、従来はallNames
の中全体に広がっていた危険性をis
を使うことでisNotNull
の中に押し込めることができた点です。Array#filter
を使っていて何か思い通りにいかないなあと思ったときはis
のことを思い出してあげると解決するかもしれません。
TypeScriptの型を活用する
ところで、さっきのallNames
はこんな書き方をすることもできます。
type MyObj = {
name?: string;
}
// 渡されたオブジェクトたちの`name`プロパティの配列を返す。
// ただし`name`が存在しないものは抜かす。
function allNames(objs: Array<MyObj>): Array<string> {
return objs.filter(obj => obj.name != null).map(obj => obj.name) as Array<string>;
}
// ["Tanaka", "Yamada"] と表示される
console.log(allNames([{name: "Tanaka"}, {}, {name: "Yamada"}]));
すなわち、map
してからfilter
するのではなくfilter
してからmap
するようになりました。この場合もやはりis
を使ってas
を消すことができます。
type MyObj = {
name?: string;
}
type MyObjWithName {
name: string;
}
function hasName(obj: MyObj): obj is MyObjWithName {
return obj.name != null;
}
// 渡されたオブジェクトたちの`name`プロパティの配列を返す。
// ただし`name`が存在しないものは抜かす。
function allNames(objs: Array<MyObj>): Array<string> {
return objs.filter(hasName).map(obj => obj.name) as Array<string>;
}
ここでは、filter
によってname
を持たないオブジェクトが消えるという意図を型で表現するためにMyObjWithName
という型を作りました。やりたいことが型で明確に表されていて悪くないですが、ひとつ問題があります。それは、MyObj
と似た型であるMyObjWithName
をわざわざ再定義したくないということです。上の例ではまだいいですが、これが次のようになったらどうでしょうか。
type MyObj = {
name?: string;
phoneNumber: string;
address1: string;
address2: string;
address3: string;
}
type MyObjWithName {
name: string;
phoneNumber: string;
address1: string;
address2: string;
address3: string;
}
安全性のためとはいえこんなコードを書いてしまったらそれはそれで敗北という気がします。
実は、TypeScriptの強力な型システムを使えばこんなことをする必要はなくなります。この場合は以下のようにすればいいのです。
type MyObj = {
name?: string;
phoneNumber: string;
address1: string;
address2: string;
address3: string;
}
type MyObjWithName = MyObj & { name: string; };
MyObjWithName
型をMyObj & { name: string; }
型として定義することができました。この&
というのは交差型(intersection型)の構文であり、左右両方の型の条件を満たすような値の型を意味します。つまり、MyObjWithName
型というのは、「MyObj
型であり、しかもname
プロパティがstring
型である」という条件を表す型になります。これはちょうど我々がやりたいことに合致していますね。
このように、TypeScriptは既存の型をちょっといじった新しい型を作るのが得意です。これにより、同じ定義を何度も書くのを防ぐことができます。ここで紹介した交差型はその中でも簡単な部類であり、他にもmapped typeはconditional typeなどを使いこなせば型の表現力はぐんと上がるでしょう。これらの型について知りたい場合は以下の記事がおすすめです(宣伝)。
余談ですが、よりエクストリームな型を使ってhasName
をMyObj
専用ではなく一般化することもでき、こんな感じになります。
function hasName<T extends { name?: unknown; }>(obj: T): obj is (T extends { name?: infer U } ? T & { name: U } : never) {
return obj.name != null;
}
TypeScriptに勝利するためにここまでやる必要があるケースはあまり無いでしょうが、できると格好いいですね。まずこんなコードを(もちろんコメントで説明は書くべきですが)受け入れられるチームを組むところから始めないといけませんが。
まとめ
この記事では、TypeScript開発においてむやみにany
やas
などの危険な機能を濫用するコードを敗北者のTypeScriptと呼びその問題点を指摘しました。
これらの危険な機能を使うのはどうしても使う必要がある場合(TypeScriptに勝利した場合)に留めなければならず、その必要性は細心の注意をもって検討しなければいけません。検討とは、それがTypeScriptの敗北であることを明らかにすることです。そのためにはTypeScriptの限界を完全に理解し、危険な機能を使わずに済ますことが不可能であることを示さなければいけません。その壁を乗り越えて危険な機能を使用する場合も、記事中で説明したように、その危険性を最小限の範囲に閉じ込めるようにしなければいけません。
三度繰り返しますが、敗北者のTypeScriptも立派なTypeScript開発の一形態です。この記事はちゃんとTypeScriptを使えないならTypeScriptやめろといった主張をしたいわけではありません。ただ、あなた方は安全性を犠牲にしているということを伝えたいのです。
意図的に安全性を犠牲にしているなら、筆者はやめたほうがいいと思いますがとやかくは言いません。それよりも、自覚せずに安全性を破壊し、TypeScriptが提供してくれる安全性が空想上の存在となっているにも関わらずその存在を信じ続けているという事態は避けるべきです。
また、TypeScript信者の観点からは、敗北者のTypeScriptを書いている人がTypeScriptの安全性がこの程度であると見誤ってしまい、それがTypeScriptの限界であると思われることは避けたいところです。これが、この記事を書いた動機の一つです。
高いレベルの安全性を誰もが必要とするわけではないとしても、TypeScriptは“正しく”利用することで高い安全性を発揮することができる言語です。TypeScriptの型システムがこんなにエクストリームなのも、JavaScriptに型を付けて安全にするという難題に対してTypeScript開発チームが取り組んだ結果なのです。
ですから、この記事を読んでTypeScriptに勝利しようと思う方が一人でも増えることを願っています。
-
この特徴から、TypeScriptは単なる静的型付けではなく「漸進的型付け」と見なされることもあります。個人的には、ランタイムのチェックが何もないのに漸進的型付けと呼んでもよいのか怪しいと思っていますが。 ↩
-
TypeScript用のリンターとしてTSlintをご存知の方がいるかもしれませんが、現在はTypeScriptでもESlint(JavaScript用のリンター)に一本化されています。 ↩
-
type-assertion
はまだ提案のissueがあるだけで実装はされていません。当該のissueに応援コメントを書いておきました(メンテナの方にドン引きされてしまいましたが。) ↩ -
実は、この型は
3
や"12345"
のような(null
とundefined
以外の)プリミティブ型も許容します。これらの値はobj.num
のようなプロパティアクセスに対して実行時エラーを出さないため、その意味ではオブジェクトと同様に扱えるからです。 ↩ -
構文上の文脈から引数の型が推論可能な場合はこの限りではありません。 ↩
-
ちなみに、返り値の型については書かない場合
any
になるのではなく、関数の中身から推論してくれます。しかし、基本的には返り値の型も明示したほうがよいでしょう。コードリーディングの助けになるだけでなく、関数の中身にバグがあるときに型エラーでそのバグを発見できる可能性があります。 ↩ -
一応述べておきますが、なるほど関数引数を省略してはいけないんだなと思って全部の引数に
any
と書いたとしても、何も状況は変わりません。--noImplicitAny
はミスによるany
の発生を防いでくれる有用なオプションですが、any
という白旗を自ら上げるならば意味はありません。 ↩ -
例えば、TypeScriptはその型情報によってエディタやIDEによる強力なサポートを可能にしています。これによる開発の効率化を主な目的とし、エラーを防ぐという安全性の方面は二の次ということもありえるでしょう。尤も、この場合でも型をちゃんと付ければ付けるほどIDEのサポートも良くなるのですが。 ↩
-
これはまあTypeScriptの敗北なのですが、TypeScriptコンパイラがSATソルバになってしまっても困るので個人的にはこれは妥当な制約ではないかと思います。 ↩