TypeScript: const n=1とconst n:1=1は何が違うのか、なぜ違うのか
TypeScriptにおいて、それぞれ以下のように書いたときの違いと、その理由を説明できますか。
const n: 1 = 1;
const n = 1;
両者の違い
エディタでホバーするといずれも 1
という型が表示され、一見同じことをしているように思えるかもしれません。しかし、これらは明確に違う型を持ちます。
その前に、すべての変数は、その変数の型とは別に、Type Narrowingという仕組みによって一時的に別の型として取り扱う機能があります。
それぞれ、仮に global type と narrowed type と呼ぶことにします。
すると、const n: 1 = 1
は (global type, narrowed type) = (1
, 1
) ですが、const n = 1
は (global type, narrowed type) = (number
, 1
) です。これは以下のようなコードで確認ができます。
const n: 1 = 1;
const getN = () => n; // inferred as () => 1
const got = getN(); // inferred as 1
const n = 1;
const getN = () => n; // inferred as () => number
const got = getN(); // inferred as number
細かい点は後で解説しますが、 「変数は内部的には大きく2種類の型を持つこと」、「 1
という型を明示的に指定するかどうかで結果が変わりうること」がそれぞれ確認できました。
なお、const n: 1 = 1;
は const n = 1 as const;
と書いても同等です。
Type Narrowingについて
Type Narrowingについてかるくおさらいします。Type Narrowingとは、if文などの条件文によって、変数がglobal typeよりも限定的になることがわかるときに、その範囲に限って変数をTypeScriptのシステムが別の型に型付けしてくれる機能です。
declare const res: string | number | boolean;
res; // inferred as string | number | boolean;
if (typeof res === 'string') {
res; // inferred as string
} else {
res; // inferred as number | boolean
}
res; // inferred as string | number | boolean;
if (res === 'abc') {
res; // inferred as "abc"
} else {
res; // inferred as string | number | boolean
}
res; // inferred as string | number | boolean;
Type Narrowingには上記以外にもいくつかの方法やパターンがありますが、この記事ではこれ以上立ち入りません。重要なのは、最初に宣言した型(global type)とは別に、変数は場所(スコープ)に応じて別の型(narrowed type)を持つのだということです。
なぜconstなのにnumberなのか
const n = 1
と書けば明らかにn
は1
のままです。let
であれば変わりうる可能性もあるというのは理解できるかと思いますが、ではなぜ、const
なのに n
は 1
にならないのでしょうか。これは結局、 as const
という構文が存在する(=リテラルの型の決定方法が2種類あること)理由そのものになります。
TypeScriptに限らず、ある程度の型推論を行う言語において、型は詳しければ詳しいほど良いとは限りません。例えば、次の例では number[]
に推論されるのを期待していますが、実際には 1[]
になってしまい、1以外の数値がpushできません。
const n: 1 = 1;
const xs = [n];
xs.push(2); // type error
逆に、明示的に型を指定するというルールであれば、以下のように書かれることになるでしょう。
const n: 1 = 1;
const xs: number[] = [n as number];
xs.push(2);
こちらは問題ありません。実のところ、 n as number
はいらないのですが、1[]
を number[]
に代入するのは実は一般的には型安全ではない(詳しくは共変性と反変性など)ので、このような書き方をしました。TypeScriptは便利さを優先し、Arrayなどの一部のパターンで反変性を無視したチェックを行います (microsoft/TypeScript#274などを参照)
話がそれましたが、反変が起こり得るため、「推論される型はより詳細なほうがいいとは限らない」ことがわかりました。
より身近な例で言えば、React利用者であれば次のような例がわかりやすいかもしれません。
...
const defaultPage = 0;
const [page, setPage] = useState(defaultPage);
...
こう書いたとき、 useState<T>
は useState<number>
に推論され、 page
は number
であり、setPage
は number
を受け付けます。
もし、 defaultPage
が (global type, narrowed type) = (0
, 0
) になってしまうと、useState<0>
に推論され、 setPage
は 0
しか受け付けてくれません。
どのようにして1になるときと、numberになるときがわかるか
例を再掲します。
const n = 1;
const getN = () => n; // inferred as () => number
const got = getN(); // inferred as number
これを見て、どのように got
が number
になると予想できるでしょうか。前提として、TypeScriptの将来のバージョンによって変わりうるもので、筆者の肌感であると断っておきます。
大体 let
と同じように扱われると考えると良いです。まず、スコープが下位の関数内の推論に使用される場合は、可変である場合と同じように扱われ、n
がその時点ではnarrowed typeの 1
かわからないものとして、 global typeのnumber
が用いられます。
逆に、同じスコープ内では、 const xs = [n];
でみたように、narrowed typeが用いられます。
スコープに関係なく、型変数のT
を推論するのに用いられる場合は、useState
の例で見たようにglobal typeが用いられます。
letとconstで差が出る場面
constであることを一切利用しないのかというと、そうでもありません。以下のように、下位スコープの関数内にnarrowed typeが引き継がれるかどうかが変わります。
const n = 1;
const fn = () => {
const xs = [n] as const; // inferred as readonly tuple [1]
};
let n = 1;
const fn = () => {
const xs = [n] as const; // inferred as readonly tuple [number]
};
追記: let flag: boolean = true と let flag = true as boolean の違い
次のような while 文を書いて、@typescript-eslint/eslint-plugin
のno-unnecessary-condition
に怒られたという経験はないでしょうか。
declare let t: number;
declare const arr: number[];
let flag = true;
while (flag) {
// ...
arr.forEach((v) => {
if (v > t) flag = false;
});
// ...
}
flag
は下位スコープの関数内でしか変更されていないので、外側のスコープではtrue
にnarrowされたままになってしまいます。.forEach
を使わずにfor文を使えばこの限りではありません。それもひとつの解決方法ですが、ここではそれができない状況を考えてみます。
ここで、let flag: boolean = true;
としても解決しません。なぜなら、 : boolean
は書く前から、上記で説明したように、書いたのと同じ効果があるのでした。 : T
では global type しか指定できないということです。
よって、その部分をいくら変えても、narrowed typeがtrue
になることを避けられません。これを避けるためには、 let flag = true as boolean
と書く必要があります。 <<expr>> as T
は、式 <<expr>>
の内容にかかわらず強制的にnarrowed typeを T
として扱うように型システムに伝えます。
まとめ
const n: 1 = 1;
とconst n = 1;
には違いがあり、エディタでホバーして表示されるnarrowed typeは1
ですが、実際はnumber
かどうかが違うのでした。
TypeScriptは利便性と厳密さの上に絶妙なバランスで成り立っています。楽しいですね。
最後にどちらを使ったほうが良いかという話ですが、場合によりけりです。const
構文を使う理由がconstantという意味であれば1
と書いたほうが良いでしょうし、immutableという意味であれば上記の例のdefaultPage
のようにnumber
にするのが良いと思います。
Discussion