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ソルバになってしまっても困るので個人的にはこれは妥当な制約ではないかと思います。 ↩