はじめに
TypeScriptでUnion型("small" | "medium" | numberのような複数の型を許容する型)を扱っていると、「同じような書き方をしているのに、片方はエラーにならず、片方はエラーになる」という不思議な現象に出会うことがあります。
本記事では、const変数への代入と、オブジェクトのプロパティへの代入とで、TypeScriptの型の絞り込み(narrowing)の挙動がなぜ異なるのかを、実際のコード例とともに解説します。
問題
以下の2つのコードを比較してみます。
type Config = "small" | "medium" | "large" | number;
const config: Config = 10;
const config2: Config = "small";
console.log(config * 1.1); // OK
type ConfigObj = {
textSize: "small" | "medium" | "large" | number;
};
const configObj: ConfigObj = { textSize: 10 };
console.log(configObj.textSize * 1.1); // Error
どちらも実質的には「10という数値をConfig型(文字列も数値も許容するUnion型)の変数に入れて掛け算する」という同じことをしているように見えます。しかし後者だけ、以下のようなエラーになります。
The left-hand side of an arithmetic operation must be of type 'any', 'number', 'bigint' or an enum type.
なぜconfigは掛け算できて、configObj.textSizeはできないのでしょうか。
解決方法
これは、TypeScriptの**制御フロー解析(control flow analysis)**が、変数とプロパティとで異なる扱いをしていることが原因です。
config(変数)の場合
const config: Config = 10;
console.log(config * 1.1); // OK
configはconstで宣言されているため、それ以降再代入されないことがコンパイラにとって保証されています。この性質を利用して、TypeScriptは「宣言上の型(Config)」とは別に、「今その変数が実際にどんな値を持っているか」という、より狭い型を追跡します。
config = 10と初期化した時点で、TypeScriptはconfigの型をnumber(正確にはリテラル型の10)として記憶し続けます。以降そのスコープ内でconfigを参照するときは、宣言時の型ではなく、この絞り込まれた型が使われるため、掛け算が可能になります。
configObj.textSize(プロパティ)の場合
const configObj: ConfigObj = { textSize: 10 };
console.log(configObj.textSize * 1.1); // Error
一方で、オブジェクトのプロパティアクセスにはこの絞り込みが基本的に適用されません。理由は、オブジェクトのプロパティはエイリアス(別の変数や関数)経由で書き換えられる可能性があるからです。
function mutate(obj: ConfigObj) {
obj.textSize = "large"; // 別の場所から変更されうる
}
mutate(configObj);
configObj自体はconstでも、textSizeというプロパティの中身まではconstによって保護されません。関数に渡されて中で書き換えられる可能性を、コンパイラは否定できないのです。
そのため、プロパティにアクセスするときは初期化時のリテラル値ではなく、型定義上の宣言型("small" | "medium" | "large" | number)がそのまま使われます。stringである可能性が消えないので、算術演算のエラーになるわけです。
つまり「文字列じゃないと言い切れない」だけ
これって要は、コンパイラが「本当に文字列じゃないの?」を証明しきれていないだけなんですよね。
証明しようとしたら、コンパイラはconfigObjが渡された先の関数を全部チェックして、どこかで書き換えられていないか追いかける必要があります。プロジェクトが大きくなるほどこれは現実的に厳しく、コンパイルもどんどん遅くなってしまいます。
なのでTypeScriptは「証明できないなら絞り込まない」という、安全側に倒したシンプルなルールを採用しているんです。
おわりに
-
const変数は再代入されないことが保証されるため、TypeScriptは初期化時のリテラル値を型として記憶し続ける(=絞り込みが効く) - オブジェクトのプロパティは、エイリアス経由で書き換えられる可能性を否定できないため、宣言された型のまま扱われる(=絞り込みが効かない)
- これは「コンパイラがサボっている」のではなく、プログラム全体を追跡するコストと実用性のバランスを取った意図的な保守的設計である
同じUnion型でも、変数かプロパティかで挙動が変わるのは最初は戸惑いますが、「コンパイラが何を保証できて、何を保証できないか」という視点で見ると納得しやすいポイントだと思います。
参考
JISOUのメンバー募集中!
プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか?
興味のある方は、ぜひホームページをのぞいてみてください!
▼▼▼
https://projisou.jp