🐡

TypeScript: const n=1とconst n:1=1は何が違うのか、なぜ違うのか

2022/06/08に公開

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

[TypeScript Playgroundで確認]

細かい点は後で解説しますが、 「変数は内部的には大きく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;

[TypeScript Playgroundで確認]

Type Narrowingには上記以外にもいくつかの方法やパターンがありますが、この記事ではこれ以上立ち入りません。重要なのは、最初に宣言した型(global type)とは別に、変数は場所(スコープ)に応じて別の型(narrowed type)を持つのだということです。

なぜconstなのにnumberなのか

const n = 1と書けば明らかにn1のままです。let であれば変わりうる可能性もあるというのは理解できるかと思いますが、ではなぜ、const なのに n1 にならないのでしょうか。これは結局、 as const という構文が存在する(=リテラルの型の決定方法が2種類あること)理由そのものになります。

TypeScriptに限らず、ある程度の型推論を行う言語において、型は詳しければ詳しいほど良いとは限りません。例えば、次の例では number[] に推論されるのを期待していますが、実際には 1[] になってしまい、1以外の数値がpushできません。

const n: 1 = 1;
const xs = [n];
xs.push(2);  // type error

[TypeScript Playgroundで確認]

逆に、明示的に型を指定するというルールであれば、以下のように書かれることになるでしょう。

const n: 1 = 1;
const xs: number[] = [n as number];
xs.push(2);

[TypeScript Playgroundで確認]

こちらは問題ありません。実のところ、 n as number はいらないのですが、1[]number[] に代入するのは実は一般的には型安全ではない(詳しくは共変性と反変性など)ので、このような書き方をしました。TypeScriptは便利さを優先し、Arrayなどの一部のパターンで反変性を無視したチェックを行います (microsoft/TypeScript#274などを参照)

話がそれましたが、反変が起こり得るため、「推論される型はより詳細なほうがいいとは限らない」ことがわかりました。

より身近な例で言えば、React利用者であれば次のような例がわかりやすいかもしれません。

...
  const defaultPage = 0;
  const [page, setPage] = useState(defaultPage);
...

[TypeScript Playgroundで確認]

こう書いたとき、 useState<T>useState<number> に推論され、 pagenumber であり、setPagenumber を受け付けます。
もし、 defaultPage が (global type, narrowed type) = (0, 0) になってしまうと、useState<0>に推論され、 setPage0 しか受け付けてくれません。

どのようにして1になるときと、numberになるときがわかるか

例を再掲します。

const n = 1;
const getN = () => n;  // inferred as () => number
const got = getN();  // inferred as number

[TypeScript Playgroundで確認]

これを見て、どのように gotnumber になると予想できるでしょうか。前提として、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]
};

[TypeScript Playgroundで確認]

追記: let flag: boolean = true と let flag = true as boolean の違い

次のような while 文を書いて、@typescript-eslint/eslint-pluginno-unnecessary-conditionに怒られたという経験はないでしょうか。

declare let t: number;
declare const arr: number[];

let flag = true;
while (flag) {
  // ...
  arr.forEach((v) => {
    if (v > t) flag = false;
  });
  // ...
}

[TypeScript Playgroundで確認]

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にするのが良いと思います。

GitHubで編集を提案

Discussion