TypeScript における let/const と control flow based type analysis

前にこのブログの記事に書いたように、TypeScript 1.4以降(ターゲットがES5の場合は1.5以降)では let/const による変数宣言ができるようになった。let は書き換え可能な変数で、const は書き換え不可能な変数だ。let 宣言と const 宣言の登場によって、var 宣言は用済みになったと言っていいだろう。

let も const もスコープに関する規則は同じなので、書き換えない変数に対して let と const のどちらを使っても違いはないはず。そう思って、個人的に書いているコードでは文字数を重視して常に let の方を使っていた。

しかし、 TypeScript 2.0 で導入された control flow based type analysis により、「書き換えない変数に対して let と const のどちらを使っても違いはない」とは言ってられなくなった。

次のようなコードを考える。変数 a のスコープを明確にするためにIIFEで囲っている。コンパイルオプションには --strictNullChecks をつける。

declare function f(): (() => void) | null;
(() => {
    let a = f();
    if (a !== null) {
        a();
    }
})();

このコードにおいて、 a の呼び出しのタイミングを遅らせてみる。

declare function f(): (() => void) | null;
(() => {
    let a = f();
    if (a !== null) {
        setTimeout(() => { a(); }, 0);
    }
})();

すると、 --strictNullChecks 付きでコンパイルした場合に

test2.ts(5,28): error TS2531: Object is possibly 'null'.

というエラーメッセージが出る。

このコードを読んだプログラマーは、「変数 a は変更されないので setTimeout のコールバック中でも a は非 null である」と判断できる。しかし、 TypeScript のコンパイラーからすれば、 setTimeout を呼び出してからコールバック関数が呼び出されるまでの間に変数 a が書き換えられる可能性があるため、 a が null である可能性を排除できない。

仮に、コンパイル時に、関数内から参照されるそれぞれの変数に対して、代入が1箇所でもあるかどうかを確かるような処理を入れれば、先の例の変数 a は変更されないと判断できて、コールバックの中でも非 null であると判断できるだろう。しかし、全ての変数についてそのようなチェックを行うのは、(想像だが)コンパイルが遅くなったりというようなコストの問題があるので、そのようなチェックは行なっていないのだろう。(少なくとも TypeScript 2.0 の時点では)

一方、変数 a の宣言を let から const に変えれば、コンパイラーから見ても変数 a は変更されないと容易に断定できる(変数 a に代入する箇所があればその時点でコンパイルエラーになる)ので、コールバック関数の中でも変数 a は非 null だと判断してくれる。

declare function f(): (() => void) | null;
(() => {
    const a = f();
    if (a !== null) {
        setTimeout(() => { a(); }, 0); // コンパイルが通る
    }
})();

そんなわけで、TypeScript 2.0 では「2文字多くタイプしてでも、 const 宣言を使った方がいい理由」ができた。