TypeScript
ポエム

敗北者のTypeScript

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とは、ひとことで言えばanyasに代表される危険な機能を濫用するTypeScriptコード(あるいはそのようなコードを生産する開発)を指します。あとで詳説しますが、これらの要素は使えば使うだけTypeScriptを使うメリットを無に帰してしまいます。

これは筆者の意見ですが、勝利者になるためにはTypeScriptのstrictオプションを有効にするのはもちろん(これはnoImplicitAnystrictNullChecksなどの厳しいチェックを有効にします)、anyasなどの危険な機能はeslint2no-explicit-anytype-assertion3などのルールを用いて原則禁止としなければいけません。これらが必要な場合はいちいち// eslint-disable-next-line: no-explicit-anyのようなコメントを用いてルールを明示的に無効化する必要があり、さらにコメントでanyなどが必要となる妥当な理由を説明していない場合はレビューで弾かれなければいけません。

TypeScriptを実際に使っている方の中には「そんな運用は現実的ではない」と思った方も多いでしょう。何がanyを使う“妥当”な理由であり何がそうでないのか理解・判断するにはコードを書く人もレビューする人もTypeScriptに精通していなければいけませんが、そのような運用ができるチームを組むのは簡単ではないでしょう。ゆえに、その通りです、TypeScriptユーザーの非常に多くはTypeScriptへの敗北に甘んじているのです

なお、ここまで読んだ方は既に察しているとは思いますが、筆者は型の信奉者であり過激派であるという点はご理解ください。実際、同じことをeslint-typescriptのリポジトリに書いたら「(anyを禁止するのはいいけどasを禁止するのは)俺だったらそんなん絶対嫌だわ(意訳)」と言われました。

ただ、思想はともあれ記事の内容は皆さんのTypeScriptコードをさらに安全にするのにきっと有用ですから、ぜひ読んでいってください。記事を読んで筆者と同じ思想に染まってくれたらさらに嬉しいです。


何が敗北なのか

ところで、ここまで読んだ方の中にはなにが敗北だ、俺は売られた喧嘩は買う主義なんだぜと思っている方もいるかもしれません。記事タイトルは、炎上して記事が拡散してほしいのでこれにしました筆者の別記事TypeScriptの型入門におけるany型の説明に由来します。


ここで、any型という言葉が出てきましたので、これについても解説します。any型は何でもありな型であり、プログラマの敗北です


ここでも槍玉に上がっているanyはTypeScriptの安全性を脅かす言語機能第1位であり、使うのは非常に危険です。後で詳説するように、anyを使う理由は主に2種類あります。ひとつはTypeScriptの力不足であり、もうひとつはプログラマの力不足です。後者のプログラマは(能力的・時間的な制約により)TypeScriptの能力をフルに使うのを諦めてanyを使うのであり、ゆえにプログラマの敗北なのです。逆に、自分がanyを使う理由はTypeScriptの力不足であると揺らがぬ自信を持って言えるならば、あなたはTypeScriptマスターであり勝利者ですから好きなように(しかし分別を持って)anyを使っても構いません。

前置きが長くなりましたが、いよいよ本題に入りましょう。まず、anyasの何が危険なのか解説していきます。


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の引数objany型なので、どんな値も受け入れます。その結果、型エラーを出すことなくfooに引数として{ num: "12345" }を渡したりnullを渡したりできます。

関数fooの中ではまずobj.numというアクセスが発生しますが、objany型なのでもちろんエラーは発生しません。また、objany型なのでobj.numany型として扱われます。

ここでanyの危険性がひとつ露呈していますね。objnullとかundefinedが入った場合はobj.numというプロパティアクセスは実行時エラーとなりますが、TypeScriptはそれを型エラーとして検出してくれませんでした。これはobjany型だからです。

さらに、return obj.num;にも問題があります。関数fooの宣言を見ると戻り値はnumber型として宣言されていますのでobj.numnumber型でなければなりませんが、obj.numany型なのでそれをnumber型として扱っても型エラーは起きません。明らかに、実際obj.numに数値が入っている保証がありませんので、これは実行時のエラーに繋がります。

このように、any型を使うとTypeScriptは本来防げたはずの実行時エラーを防いでくれなくなります。これではTypeScriptを利用するメリットをプログラマ自ら殺していることになりますから、TypeScriptが提供する安全性を最大限享受するためにはanyを使うべきではありません。

とはいえ、anyを使っているコードでも実際には安全という場合も多いでしょう。そのようなコードを書いている方ならば、こんなのちょっと考えれば安全だって分かるんだからanyを使ってもいいだろと思うかもしれません。しかし、筆者はそのような考え方は推奨しません。なぜなら、anyはコードの負債になるからです。

anyとコードに書くということはそのコードが危険であると積極的に主張しているのも同然ですから、コードに書かれたそのanyはこれからそのコードを見るすべての人の思考時間を奪います。anyと書かなければTypeScriptが安全性を保証してくれたのに、anyと書いてしまったばかりにそのコードの安全性を保証するのは人間の責任となるのです。これこそがまさに、TypeScriptを使う意味が薄れるということです。

anyの安全性をコメント等でちゃんと書いておくという方法もありますが、わざわざそんなコメントを書くくらいならanyを消してちゃんと型を書いたほうがよいです。もしどうしてもanyを消せないならば、それはロジックが複雑すぎます。TypeScriptがどうとか以前に、安全性を保証できないロジックはリファクタリングによって消されるべきです。人間が考えてもどうにもならないということは、テストによってもどうにもならないということなのですから。


どうすればよかったのか

では、さっきの例を正しく直してみましょう。いきなり正解を述べると、この場合はobjの型をanyではなく{ num?: number }とすべきです。この型の意味は、「オブジェクトであり、プロパティnumを持っていても持っていなくてもいいが、持っている場合はプロパティnumnumber型でないといけない」という意味です4。この型を用いることで、関数fooに変な値が渡されて実行時エラーとなるのを防止できます。

function foo(obj: { num?: number }): number {

if (obj.num != null) {
return obj.num;
}
// obj.numが存在しない場合もあるのでその場合は0を返す
return 0;
}

// ↓これはエラーになる
foo(null);
// ↓これもエラーになる
const val = foo({ num: "12345" });

この例では、objの型にanyを使うのをやめたことで実行時エラーの原因(foo(null)とか)を検出することができました。つまり、TypeScriptが提供する安全性を正しく活用できたことになります。

もしobjに与えるべき型が分からずとりあえずanyにしていたとしたら、それはプログラマの敗北としか言えませんね。正解が分からなかった方は、敗北者から抜け出すためにもっと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を無理に使わなくてもいい」という主張をする人もいます。それはまあ正しいのですが、筆者が危惧するのはそのような負け犬根性が染み付いてしまいそれがTypeScriptの唯一のあり方であると思われることです。そのような主張が妥当性を持つのはあくまでJavaScriptからTypeScriptへの移行の途中の話であり、TypeScriptに移行が完了した後も--noImplicitAnyオプションありでコンパイルが通らないようなコードはやはり事実上anyを使用している敗北者のTypeScriptなのです。

そろそろ顔が赤く染まってきた読者も多いと思うので再度述べておきますが、敗北者のTypeScriptを書くなと言っているわけではありません。それでもTypeScriptを使わないよりはずっと安全です

ただ、一つ述べておきたいのは、--noImplicitAnyを使わなくてもいいという主張は「--noImplicitAnyを使わないで達成できる程度の安全性で満足できるなら」という枕詞が付きます。TypeScriptの安全性をどの程度享受し、そしてどの程度捨てるのかはプロジェクトが選択できることであり、TypeScriptの使われ方というのはその選択によって大きく異なったものとなります7

ですから、--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オプションありならば、nullnumber型の値だと見なされなくなるのでこのコードはコンパイルエラーが発生します。嬉しいですね。

--strictNullChecksありの状況では、nullを扱う場面では明示的にnullを使用します。例えば、上記のコードをfoonullも受け取れるようにするには次のようにします。

// 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)の部分ではnumnullである可能性は既に排除されていると判断されてnumnumber型となり、コンパイルエラーにならずに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が使われていますね。関数fooobj.nameを返したいのですが、objMyObj1型でありnameプロパティが存在しないのでこれは型エラーとなります。そこで、エラーを回避するために一時的にobjの型をMyObj2型にしてしまうのが(obj as MyObj2)です。これによりobjMyObj2型となり、nameプロパティを持っていると見なされるので型エラーが消えます。

これのどこが危険かはお分かりですね。foo({ id: 123 })のように実際はnameプロパティを持たないオブジェクトをfooに渡すと返り値はundefinedです。これは、fooの返り値の型がstringであることと合致していませんので、型情報と実行時の挙動が異なることになり実行時エラーに繋がります。

このように、asはTypeScriptに文句を言わせずに型に対して危険な修正を行うことができる(具体的には型をその部分型に修正できる)機能です。ですから、正当な理由無く使うべきではありません

まあ実際のところ、anyは絶対だめだけどasはまあ使ってもいいんじゃないという意見も見られます。それは、TypeScriptの敗北が発生したときにasを使って対応することが多いからです。つまり、TypeScriptの推論能力が足りないせいでコードに型エラーが発生する場合に、TypeScriptを黙らせる手段としてasを使うのです。

言うまでもなく、asを使うためにはそれがどちらの敗北なのかを見極めるべきです。TypeScriptの敗北ならばasを使ってもよいですが、自分の敗北(型の宣言が間違っているせいで発生した矛盾をasで無理やり解消しようとしているとか)の場合にasを使うべきではありません。

ちなみに、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に独自の構文として後置の!が知られています。これは値がnullundefinedかもしれない可能性を無視するという構文です。とりあえず例を見てください。

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型の宣言を見れば分かるとおり、objnameプロパティは存在しないかもしれません。これにより、obj.namestring | 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.nameobj2.nameの少なくとも一方は存在する」という条件が満たされることになりますが、TypeScriptはこのような「どちらか一方が成り立つけどどちらなのかは不明」というような条件を理解できません8。それにより、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)が真かどうか判断したことによって、その中ではobjArray<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");
}


余談: unknownas

実は、ここで出てきたunknownasを併用することで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);
}

新しい関数isNotNullis型を使用しています。これは、返り値がtrueならxExclude<T, null | undefined>型であるという意味です。Tというのは引数xの型であり、Exclude<T, null | undefined>というのはざっくり言えば「Tからnullundefinedの可能性を排除した型」です。例えばTstring | 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などを使いこなせば型の表現力はぐんと上がるでしょう。これらの型について知りたい場合は以下の記事がおすすめです(宣伝)。

余談ですが、よりエクストリームな型を使ってhasNameMyObj専用ではなく一般化することもでき、こんな感じになります。

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開発においてむやみにanyasなどの危険な機能を濫用するコードを敗北者のTypeScriptと呼びその問題点を指摘しました。

これらの危険な機能を使うのはどうしても使う必要がある場合に留めなければならず、その必要性は細心の注意をもって検討しなければいけません。使用する場合も、記事中で説明したように、その危険性を最小限の範囲に閉じ込めるようにしなければいけません。

三度繰り返しますが、敗北者のTypeScriptも立派なTypeScript開発の一形態です。この記事はちゃんとTypeScriptを使えないならTypeScriptやめろといった主張をしたいわけではありません。ただ、あなた方は安全性を犠牲にしているということを伝えたいのです。

安全性を犠牲にするのはひとつの立派な選択であり、それでもなおTypeScriptを使うことには意味があります。しかしTypeScript信者の観点からは、敗北者のTypeScriptを書いている人がTypeScriptの安全性がこの程度であると見誤ってしまい、それがTypeScriptの限界であると思われることを問題視していました。

そのレベルの安全性を誰もが必要とするわけではないとしても、TypeScriptは“正しく”利用することで高い安全性を発揮することができる言語です。TypeScriptの型システムがこんなにエクストリームなのも、JavaScriptに型を付けて安全にするという難題に対してTypeScript開発チームが取り組んだ結果なのです。

ですから、この記事を読んでTypeScriptに勝利しようと思う方が一人でも増えることを願っています。





  1. この特徴から、TypeScriptは単なる静的型付けではなく「漸進的型付け」と見なされることもあります。個人的には、ランタイムのチェックが何もないのに漸進的型付けと呼んでもよいのか怪しいと思っていますが。 



  2. TypeScript用のリンターとしてはtslintが知られていますが、現在はこれを廃止してeslint(JavaScript用のリンター)に一本化しようという動きが進んでいます。 



  3. type-assertionはまだ提案のissueがあるだけで実装はされていません。当該のissueに応援コメントを書いておきました(メンテナの方にドン引きされてしまいましたが。) 



  4. 実は、この型は3"12345"のような(nullundefined以外の)プリミティブ型も許容します。これらの値はobj.numのようなプロパティアクセスに対して実行時エラーを出さないため、その意味ではオブジェクトと同様に扱えるからです。 



  5. 構文上の文脈から引数の型が推論可能な場合はこの限りではありません。 



  6. ちなみに、返り値の型については書かない場合anyになるのではなく、関数の中身から推論してくれます。しかし、基本的には返り値の型も明示したほうがよいでしょう。コードリーディングの助けになるだけでなく、関数の中身にバグがあるときに型エラーでそのバグを発見できる可能性があります。 



  7. 例えば、TypeScriptはその型情報によってエディタやIDEによる強力なサポートを可能にしています。これによる開発の効率化を主な目的とし、エラーを防ぐという安全性の方面は二の次ということもありえるでしょう。尤も、この場合でも型をちゃんと付ければ付けるほどIDEのサポートも良くなるのですが。 



  8. これはまあTypeScriptの敗北なのですが、TypeScriptコンパイラがSATソルバになってしまっても困るので個人的にはこれは妥当な制約ではないかと思います。