Edited at

TypeScript 3.7の`asserts x is T`型はどのように危険なのか

TypeScirptの動向を少し熱心に追っている方ならば、8月頭にAnders HejlsbergさんがTypeScriptリポジトリに次のプルリクエストを出したことは記憶に新しいでしょう。

これはTypeScript 3.7で導入される予定の機能で、関数の返り値の型宣言においてasserts x is Txは引数名でTは型)という構文を書くことが可能になるというものです。

この機能はたいへん面白いのですが、誤った使い方をするととても危険です。そこで、この記事では、assertsという新しい型述語1を正しく使いこなせるように皆さんをガイドします。


3行でまとめると



  • assertsによる宣言はTypeScriptにより正しさがチェックされるわけではありません。

  • よって、assertsを使う場合安全性を保証する責任はコンパイラではなく我々にあります。

  • でもassertsはめっちゃ便利なので頑張って安全に使っていきましょう。

ということです。では早速、この機能はどういうものなのか、そしてそれがどう危険なのか、安全に使うにはどうすればいいのかを解説していきます。


asserts型述語の解説

今回の新機能をここではasserts型述語を呼ぶことにします(asserts型の述語ではなくassertsの型述語です)。このasserts型述語は、先ほども述べた通り関数の返り値の型宣言として書くことができます。一言で言えば、これは「関数が例外を投げずに無事に終了したならば、型述語の条件が満たされる」という宣言です。

簡単な例はこんな感じです。

function assertIsNumber(x: unknown): asserts x is number {

if (typeof x !== "number") {
throw new Error("BOOM");
}
}

このassertIsNumberは、「この関数がthrowしなければ引数xnumber型である」という宣言がされていることになります。実際、関数の中身を読むとそう書いてありますね。この関数は、引数xの型をtypeofでチェックし、それがnumberでなければ例外を発生させます。ということは、この関数が正常終了するのは引数xが数値だった場合だけです。このことにより、assertIsNumberが正常終了したならばxが数値であると仮定してよく、それを型の世界で表すとxnumber型として扱ってもよいということになります。このことを、asserts x is numberという返り値の型で表しているのです。

使用例はこんな感じになります。

function assertIsNumber(x: unknown): asserts x is number {

if (typeof x !== "number") {
throw new Error("BOOM");
}
}

// someValueはunknown型
const someValue: unknown = "foobar";

assertIsNumber(someValue);
// ここではsomeValueはnumber型

console.log(someValue.toFixed(1));

この例では最初someValue変数がunknown型だったのに、assertIsNumber関数呼び出しを通過した後はnumber型に変化しています。これがasserts型述語の効果です。

ちなみに、asserts型述語は、asserts x is Tという構文の他に、単にasserts xとだけ書く構文もあります。これは、「関数が正常終了するならばxはtruthyな値(真偽値に変換するとtrueになる値)である」という意味になります。

asserts型述語の説明はこれだけです。とても簡単ですね(記事執筆時点でまだPRがマージされておらず、追加機能の議論もあるため実際にTypeScript 3.7がリリースされるときにはもう少し豪華になっているかもしれません)。


assertsの使い道

このasserts型述語は、その名が示す通りアサーション関数をいい感じにサポートすることがそもそもの目的です。アサーション関数というのは条件を満たさなかったら例外を発生させる関数です。

アサーション関数は、テストコードや例外ベースのバリデーション処理などで使われがちですね。例えば、謎のデータを返すgetUnknownData()関数をテストしたい場合を考えます。今回、実はこの関数が数値を、しかも3の倍数を返すことをテストしたいとしましょう。

数値かどうかテストできるassertIsNumber関数はあるけど、3の倍数かどうかテストできる関数は流石に無いので汎用のasserts関数を使うというシチュエーションを考えるとだいたいテストはこんな感じに書けます。

it('getUnknownData() は3の倍数を返す', ()=> {

const data = getUnknownData();

assertIsNumber(data);

asserts(data % 3 === 0);
});

ただし、assertIsNumberは引数が数値でなければ例外を発生させる関数で、assertは引数がtrueでなければ例外を発生させる関数です。テストなので例外が発生すれば当然テストが落ちますね。

では、これがTypeScriptだとどうでしょうか。とりあえずassertsのことは一旦忘れて型を付けてみましょう。getUnknownData()は謎のデータを返すので、返り値は当然unknownです。

function getUnknownData(): unknown { return 123; }

function assertIsNumber(value: unknown): void { /* ... */ }
function assert(condition: boolean): void { /* ... */ }

it('getUnknownData() は3の倍数を返す', ()=> {
const data = getUnknownData();

assertIsNumber(data);

assert(data % 3 === 0);
// ^^^^ ここでエラーが発生
});

上の定義だと、ソースコード中に示した通りdata % 3のところでエラーが発生します。これは、dataの型がunknownなので%という数値演算ができないからです。

実際のところassertIsNumberをくぐり抜けたことでdataが数値であることは保証されているのですが、従来はこれを我々は知っていてもTypeScriptコンパイラが知らなかったため、例えば((data as number) % 3のようにasを使ってごまかす必要がありました(そもそもdataanyとかにする禁断の技もありますが)。

今回の新機能であるところのassertsを使うと、この型エラーをいい感じに排除することができます。assert系の関数の型をいい感じに変えるといいですね。次のようにするとエラーが消えます(assertIsNumber関数だけ変えれば今回は大丈夫です。assert関数のほうはおまけです)。

function assertIsNumber(value: unknown): asserts value is number { /* ... */ }

function assert(condition: boolean): asserts condition { /* ... */ }

こうすると、assertIsNumberをエラーを出さずにくぐり抜けた時点でdataの型がnumberとなり、data % 3の型エラーを回避できるのです。

これが基本的なasserts型述語の使い方でした。応用編もあるのですが、それは本題でないので記事の後半で紹介します。

なお、一点誤解しないでいただきたいのは、assertsと書いてもランタイムには一切影響を与えないということです。そもそもTypeScriptの大きな特徴は型がランタイムに影響を与えないということであり、assertsも例外ではありません。assertsが効果を発揮するのはあくまで型チェック時のみであり、実行時には無関係です。このことは次に説明するassertsの危険性にもすこし関わってきます。


assertsの危険性

ここからがやっと本題です。いま紹介した便利なasserts型述語なのですが、実は迂闊に使うと危険なのです。危険というのはどういうことかというと、せっかくTypeScriptが保証してくれている安全性を破壊してしまうという意味です。極論、assertsの誤った使い方をしてしまうとTypeScriptを使っている意味が消え失せます。

assertsの危険性とは何かといういと、嘘をついても怒られないという点に集約されます。assertsによる宣言は完全に自己申告であり、assertsで書かれた宣言をTypeScriptは一切疑わずに信じ込んでしまうのです。

ということは、最悪次のような宣言をしても問題なくコンパイルが通るということです。

function assertIsNumber(value: unknown): asserts value is number {

// 間違って型が文字列かどうかチェックしてしまった!!!!!!!!!
if (typeof value !== "string") {
throw new Error("BOOM");
}
}

このassertIsNumberは^明らかに実装がおかしくて、返り値がasserts value is numberというのは嘘っぱちです。しかし、TypeScriptはこれに文句を言いません。もちろん、先述の通り何かランタイムのチェックが自動で挿入されるわけでもありません。ということは、上の関数を使うとバグ発生へまっしぐらということです。実行時エラーが発生してしまうということは、TypeScriptの安全性が無に帰してしまったということです。


実行時エラーの例

const data = getUnknownData();

// number以外を弾いたつもり
assertIsNumber(data);
// ここではdataはnumber型(実装が変なので実際は文字列が来る可能性がある)

console.log(data.toFixed(10)); // 型チェックが通るけど実行時にエラーが発生


これがasserts型述語の危険性です。assertsと書いた時点でその宣言が正しいことを保証する義務はプログラマにあるのであり、TypeScriptに頼ることはできないのです。

よって、assertsを使うときは自分が嘘をついていないか注意深く見直さなければいけないということです。コードレビュー中にコードにassertsが登場したら黄色信号、要注意ポイントです。

このように、TypeScriptの型チェックに頼らずに自ら注意しないといけないというのは、any型やasなどの(自分で安全性を保証する必要があるという意味で)TypeScriptの危険な機能に共通する性質です。今回は、この危険な機能ファミリーにひとつ仲間が加わったということになります。ちなみに、asserts以外の危険な機能については筆者による以下の記事で詳しく取り扱っています。(そのうち向こうの記事にも追記します。)

この記事で皆さんに一番伝えたかったことは、assertsはこのような危険な機能の一員であることを理解して使わなければならないということです。静的型付け言語は型をうっかり間違ってもコンパイラが指摘してくれることで安全性を担保できるのが良い点ですが、assertsを使う場合はそうもいかず、皆さんに安全性を担保する責任が一部生じてしまうのです。

assertsというのはある種のインターフェースであり、その宣言の真の意味は「この関数がこういう意味を持つことはプログラマが保証するから安心して使ってね」という意味なのです。

以上で一番伝えたかったことは終わりなのですが、この記事はもうすこし続きます。次の節では、assertsの本質に迫りつつこの点をもうちょっと深堀りしていきます。


型述語の本質

今回紹介したassertsという新機能は、ある意味では非常に革新的なのですが、ある意味では別に全然目新しくもない変更です。

というのも、TypeScriptには既にisを使う構文がありましたよね。それは、(asserts無しで)x is Tという構文で表されるuser-defined type guardsです。多分、このx is Tも(assertsではない普通の)型述語と呼ばれています。今回assertsが加わったことで、型述語がx is Tasserts x is Tの2種類になったわけです。


既存の型述語

このx is Tはこんな感じで使うのでした。

function isNumber(value: unknown): value is number {

return typeof value === "number";
}

const data = getUnknownData();

if (isNumber(data)) {
// ここではdataはnumber型
console.log(data.toFixed(0));
}

すでにお分かりの通り、同じisというキーワードを使っているだけあって、この既存機能と今回の新機能はとても類似しています。特に、どちらも変数の型を絞り込めるという点で共通していますね。違うのはその方法です。

既存の型述語では、x is Tという返り値の関数は真偽値を返さなければいけません。返り値がtrueならばxT型であるということになります(当然、これも自己申告です。x is Tもまた危険な機能のひとつです)。

それに対し、asserts型述語では関数自体の返り値の型はvoidです。関数の返り値ではなく、関数が正常に終了したかどうか(例外を発生させなかったかどうか)によってx is Tが満たされるかどうかが判定されるのです。従来の型述語はif文(あるいはほかの条件分岐構文)と組み合わせる必要があったのに対し、assertsは関数を呼ぶだけでいいというのが新しいですね。その代わり、例外という機構に頼らなければいけません。適材適所で使い分けましょう。


2つの型述語の本質的な共通点

さて、このように2種類の型述語の似ている点と異なる点を紹介しましたが、この2つにはもっと本質的な共通点があります。それはどちらも制御フロー解析に影響を与えるロジックを関数として分離するための機能であるという点です。


x is Tと制御フロー

すなわち、そもそもTypeScriptにはif文などを適切に解釈して型を絞りこむ機能があったわけです。これはunion型などとも関わるTypeScriptの非常に重要な機能です。例えば、上の例などはわざわざisNumberなんて関数を作らなくてもこのように書けます。


if文による型の絞り込みの例

const data = getUnknownData();

if (typeof data === "number") {
// ここではdataはnumber型
}


これは、TypeScriptがifの条件部分に現れたtypeof data === "number"という式をよしなに解釈してdatanumber型に絞り込んでいるということができます。正確性を犠牲にして言えば、これはtypeof data === "number"という式の型がdata is number型に推論されるという理解もできます。

ところが、この条件部分がより複雑になってくると2つの問題が発生します。1つは長い式をif文の中に直書きするのは辛いということ、もう1つはあまりに凝ったことをやるとTypeScriptの理解能力を超えてしまう(正しく型の絞り込みができない)ということです。

前者の問題については、単純に式を関数に押し込めて以下のようなisNumber関数のようなものを作ってもよいのですが、それではTypeScriptはisNumberの意味を自動的には理解してくれず、if文の条件式に使っても何も起きません。

// data is number と明示的に書かないと if (isNumber(value)) のように書いても絞り込まれない!

function isNumber(data: unknown) {
return typeof data === "number";
}

自動的にこれをやってくれないのは、恐らく2つ目の問題のためでしょう。つまり、関数に切り出したいほど複雑なロジックの場合は処理を追うのはすぐに限界が来て、実用可能なレベルで自動推論するのは無理があります。そのため、返り値にx is Tを書くことで明示的に宣言させる仕組みになっているのです。

そして、前述の通りx is TはTypeScriptが何の保証も行ってくれないため危険なのですが、それにも関わらず偉い点は、危険性が自動的に関数の中に閉じ込められるという点です。

レビュー時に関数の返り値の型にdata is numberなどと書いてあったらそれは黄色信号なのですが、その場合どこに注意を払えばいいのかは極めて明白です。そう、まさにその関数の中ですね。

TypeScriptの危険な機能を扱う際に最もやってはいけないのは危険性の影響範囲を制御できずに無秩序に拡散させてしまうこと(any型の関数を作って放置するとか)なのですが、このx is T型述語は自動的に危険性が関数の中に閉じ込められる(すなわち、その関数が安全なことを人の手で確かめれば外まで見なくても大丈夫)ためこの問題が発生しないのです。それゆえに、TypeScriptにおける危険性マネジメントにおいて、このx is Tというのはとても取り回しがいい存在です。


asserts x is Tと制御フロー

さて、ちょっと回り道しましたが話をasserts型述語に戻しましょう。今まで説明したことは全てasserts型述語に当てはまります。例えばassertsNumber関数というのは、次のようなロジックを関数に閉じ込めたものであるということができます。

// dataはunknown型

const data = getUnknownData();

if (typeof data !== "number") {
throw new Error("BOOM");
}
// ここではdataはnumber型
console.log(data.toFixed(0));

実は、TypeScriptはこのようにthrow含んだ制御フローをやはりいい感じに解析してくれて、上の例くらいなら最終的にdatanumber型であることを見抜いてくれます。

しかし、throwを含むロジック(上の例だとif文の3行ですね)を別の関数に切り出すと、TypeScriptは自動的に解析を行ってくれなくなります。それはちょうど普通のx is Tと同じ理由によるものです。

ですから、まさに同じ議論を適用することによってasserts x is Tという型述語の存在意義が説明できます。まとめ直すと、例外が発生する条件などが複雑化したときにTypeScriptが制御フローだけから自動でその意味を推論するのは無理があるため、ユーザーが明示的にasserts x is T型述語を書くことによって、この関数がthrowしない場合に何が保証されるのかを宣言できるようにしているのです。

もちろん、メリットデメリットもx is Tと全く同様です。デメリットはTypeScriptによる検査がなく自分の責任で安全性を保証しなければいけないことであり、メリットはそのような危険性がやはり関数内に閉じ込められていることです。

ここまでの説明により、もはや2種類の型述語の共通点は明らかですね。それは、どちらも制御フローによるロジックを関数に切り出すための機能であるということです。唯一の違いは、x is Tは条件分岐を切り出す一方でasserts x is Tは例外を切り出すという点です。

2つの違いはたったこれだけであり、ゆえにTypeScript 3.7のasserts型述語というのはある意味では全然目新しくないのです。既にx is Tについて我々が知っていたことがそのままasserts x is Tにも当てはまるのであり、既にx is Tの使い方をわきまえていたならばasserts x is Tを受け入れるのは非常に簡単でしょう。そうでない方もご安心ください。この記事をここまで読んだ貴方は大丈夫です。


型述語の使いどころと使いかた

結論から言ってしまえば、(既存のx is T型述語と同じく)asserts x is Tというのはライブラリの型を付けたいとき(あるいはプロジェクト内でもutil的な関数に型を付けたいとき)に最も重宝するでしょう。

これらの型述語を使った時点で関数の中は危険領域なのですから、あまりに濫用するとTypeScriptに任せられない部分が増えて開発者の負担が増えてしまいます。よって、これらの機能が作り出す危険領域は最小限にすべきです。

その観点からして最も安全なのは、既成のライブラリを使う場合です。既成のライブラリにasserts型述語などがついていれば、(ライブラリの型定義が嘘をついていないという前提のもとではありますが)我々が何も負担することなく型述語の利便性を享受できます。

ですから、ライブラリを作る場合は積極的にこれらの型を使っていきたいですね。

しかしそれだけではなく、実際のところ、プログラムで扱うデータに厳密に型を付けようとすればするほど型述語のお世話になる機会は増えていきます。(筆者はあまり好きではないのですが)Branded typeのようなテクニックを使う際にも型述語は有効でしょう。とにかく一番重要なのは、(敗北者のTypeScriptで説明したことの繰り返しですが)危険領域を小さく抑えることです。危険領域を小さく抑えることにより安全性を担保するための労力が小さくなり、バグの危険性も小さくなります。これこそが型述語のうまい使い方と言えます。

この記事を読んだ皆さんはやらないでしょうが、asserts x is Tなどと書いておきながらそれが嘘であるというのは最悪のケースです。関数内の安全性を保証しなかったばかりに、関数外部まで危険性(型システムで防げないバグの可能性)の影響が及んでいることになります。これはTypeScriptを使っている意味を大きく損ないますから、避けなければいけません。本当に気をつけましょう。


まとめ

この記事ではTypeScript 3.7に入る予定の新機能、asserts x is T型述語を紹介し、その危険性や望ましい扱い方を解説しました。

といっても、途中で説明した通りこの新機能は既存のx is Tと非常に似通ったものであり、それゆえこの記事の内容はほとんどが両者に当てはまります。

結論をもう一度繰り返すと、これらの型述語をTypeScirptは盲信します。よって型述語を使用した時点で関数の中は危険地帯である、ゆえに危険地帯の範囲を最低限にせよ、ということです。

危険性ばかりを強調しましたが、危険だから使うななどということは言いません。むしろ、筆者はこの危険性をいかに乗りこなすかがTypeScriptの醍醐味であると考えています。TypeScriptの非常に強力な型システムの恩恵を受けるには危険性は避けて通ることはできません。よく分からず危険性を使うのが初心者、危険性を避けるのが中級者、危険性を乗りこなすのが上級者です。

皆さんもぜひassertsを使いこなして快適なTypeScriptライフを送ってください。

といってもTypeScript 3.7がリリースされるのは11月末とかそれくらいになりそうですが。ちなみに、あのoptional chainingx?.yが入るのもTS 3.7の予定です。未来ですね。


おまけ

結論としてasserts型述語は既存の型述語と本質的に同じであると述べましたが、その一方で少し前にどこかで「assertsはある意味で非常に革新的である」とも述べました。せっかくなので、この記事ではその部分にも触れようと思います。

asserts型述語の何が革新的なのかというと、(今の所返り値がvoidであるという制限があるものの)関数を呼ぶだけで変数の型を変えられるという点です。従来TypeScriptにおいて一度宣言された関数の型を変えるのは困難であり、それこそ条件分岐などを使って絞り込むしかない状態でした。それが、関数を呼ぶだけでよいとなると夢が広がります。

さっそく例をお見せしましょう。なお、例は全て記事の冒頭で紹介したプルリクのコミット2c36249ed6bd63f4fd66813985ae2dff681349a5で動作確認しています。


オブジェクトをデフォルト値で埋める関数

関数がオプションをオブジェクトでまとめて受け取るというのは非常によくあるシチュエーションです。そして、オプションたちは往々にして省略可能です。もし受け取ったオプションが省略されていた場合はデフォルト値を使用することになるでしょう。

asserts型述語を用いることで、この処理をいい感じに書けます。やや長いですが例を見ましょう。

interface Options {

foo: string;
bar: number;
}

function useOptions(options: Partial<Options>) {
// これはエラー (options.fooはundefinedかもしれないので)
console.log(options.foo.length);

// オプションを埋める
fillOptions(options);
// これはエラーにならない!!!!!
console.log(options.foo.length);
}

function fillOptions(options: Partial<Options>): asserts options is Options {
if (options.foo === undefined) {
options.foo = "";
}
if (options.bar === undefined) {
options.bar = 0;
}
}

この例で登場するOptions型はfoobarの2つのプロパティを持つオブジェクトの型です。次に定義されている関数useOptionsはこのオプションたちをオブジェクトで受け取りますが、foobarは省略可能ということを表現するために引数の型はPartial<Options>型になっています。

となると、これをいきなりoptions.foo.lengthのように使うのはまずいです。options.foostring型のはずでしたが、今回はPartialによってoptions.fooが存在しない、すなわちundefinedである可能性が発生しているからです。

今回鍵となるのがfillOptions関数です。この関数を呼ぶとあら不思議、なんとその後options.fooを使ってもエラーにならないのです。

その秘密はもちろんasserts型述語です。今回fillOptionsの返り値はasserts options is Optionsです。これはつまり、fillOptionsを呼び出して以降はoptionsは(Partial<Options>ではなく)Optionsであるという宣言になります。これにより、fillOptionsを呼び出して以降はoptions.fooundefinedである可能性が消えるのでoptions.foo.lengthとしてもエラーにならなかったのです。

もちろん、この記事で説明した通り、fillOptionsのこの宣言が正しいことを保証するのは書いた人の役目です。今回は確かにfillOptionsがこの宣言通りの動作をしている(foobarundefinedである可能性を潰している)ことが分かりますから、これで大丈夫です。

このように、asserts型述語によってオブジェクト等に対するミューテーション(書き換え)を型で表現することができます。これは今までにない非常に革新的な機能です。


Builderパターン再び

さらに、実はasserts型述語によって変えられるのは引数の型だけでなく、実はthisの型も変えることができます。これを利用してBuilderパターンをTypeScriptを書いてみましょう。「再び」というのは筆者の以下の既存記事のことを指しています。

上の記事ではBuilderオブジェクトはメソッドチェーンの形で使うことを強いられていましたが、asserts型述語を手に入れた我々にはもはやそのような制限はありません。細かなアイデアは上の記事と同じなので、長々とした説明は飛ばして早速結果をお見せします。また、本質的ではないのでランタイム実装は省略して型部分だけ取り出しました。

type Builder<Props, Result> = ({} extends Props

? {
build: () => Result;
}
: {}) & BuilderMethods<Props, Result>

interface BuilderMethods<Props, Result> {
set<Key extends keyof Props>(key: Key, value: Props[Key]): asserts this is Builder<Pick<Props, Exclude<keyof Props, Key>>, Result>
}

// ----- 使用例 -----
// builder は foo と bar を指定する必要があるBuilder
declare const builder: Builder<{
foo: string;
bar: number;
}, string>
// まずfooを指定
builder.set("foo", "foobar");
// 次にbarを指定
builder.set("bar", 123);

// するとbuild()が呼べる
const result: string = builder.build();

前半がBuilderパターンの実装で後半が例です。例は、まずなんらかの方法でBuilder<{ foo: string; bar: number}, string>という型のビルダーbuilderを入手します。型が示す通り、これはfoobarを指定するとbuildが呼べるようになります。今回は実装上の都合で、foobarという値はsetメソッドを通じて指定することにしました。一つずつsetメソッドを呼び出すことでfoobarの値を指定します。

すると、例の最後の行のようにビルダーのbuild()メソッドが呼べるようになりました。

このようにsetメソッドで必要な値を供給してbuildメソッドで値を生成するというのがビルダーの使い方です。安全にビルダーを使うためには、buildは必要な値を全部setで供給するまで呼び出してはいけません。今回はこの制限が型レベルで実現しており、次のようなものは型エラーで弾くことができます。


エラー例

declare const builder2: Builder<{

foo: string;
bar: number;
}, string>

// まずfooを指定
builder2.set("foo", "foobar");

// まだbarを指定していないのでこれは型エラー
// TS2339: Property 'build' does not exist on type
// 'BuilderMethods<Pick<{ foo: string; bar: number; }, "bar">, string>'.
builder2.build()


今回は別のビルダーbuilder2を使いますが、setbarを指定し忘れています。よって、builder2.build()を呼び出そうとしても型エラーになります。

また、次の例もエラーです。

declare const builder3: Builder<{

foo: string;
bar: number;
}, string>

// まずfooを指定
builder3.set("foo", "foobar");

// もう一度fooを指定しようとすると型エラー
// error TS2345: Argument of type '"foo"' is not assignable to parameter of type '"bar"'.
builder3.set("foo", "吉野家")

このように、asserts型述語を活用(悪用?)することで、関数やメソッドの呼出によって型を操作することができ、「メソッドの呼出の順番」などといったものに対する型チェックを(自由自在にとまでは行きませんが)行うことができます。これはやばいですね。

以上です。





  1. isというキーワードを使って書かれる物を型述語と呼んでいるようです。Type predicateの直訳ですが本当に訳語がこれでいいのかちょっと不安です。