TypeScript では、変数や関数に毎回きっちり型注釈(: 型)を書かなくても、コンパイラが値から型を自動で判断してくれます。これが「型推論(Type Inference)」です。とはいえ、推論に任せてよい場面もあれば、あえて型注釈を書くべき場面もあります。この記事では、型推論がどんな仕組みで型を決めているのか、let と const で推論が変わる理由、コールバックの引数が推論される文脈的型付け、そして型注釈をあえて書くべき場面までを、初心者〜中級者向けに解説します。
目次
型推論とは|TypeScript が型を自動で判断する仕組み
型推論とは、明示的な型注釈がないときに、TypeScript が式や初期値から型を自動的に決めてくれる仕組みのことです。もっとも分かりやすいのは、変数を初期値付きで宣言したときです。次のように書くと、message には型注釈を付けていないのに string 型だと判断されます。
let message = "hello"; // 型注釈は書いていない message.toUpperCase(); // OK:message は string と推論される message = 123; // エラー:number は string に代入できない
初期値が文字列リテラルなので、TypeScript は message を string 型と推論します。そのため文字列専用の toUpperCase() が呼べますし、あとから数値を代入しようとするとエラーになります。型注釈を書かなくても、型のチェックはしっかり働いているわけです。この「初期値からの推論」が型推論のもっとも基本的な形です。
関数の戻り値も推論される
型推論は変数だけでなく、関数の戻り値にも働きます。関数の本体が返す値から、戻り値の型が自動的に決まります。次の add 関数は戻り値の型注釈を書いていませんが、number + number の結果を返しているため、戻り値は number と推論されます。
// 引数には型注釈を付けているが、戻り値の型注釈は書いていない
function add(a: number, b: number) {
return a + b; // number 同士の加算
}
const total = add(1, 2); // total は number と推論される
total.toFixed(2); // OK
戻り値が number と推論されるため、それを受け取る total も number になります。このように、引数の型さえ決まっていれば戻り値は本体から推論できることが多く、戻り値の型注釈は省略されることがよくあります。ただし後述するように、公開する関数では戻り値をあえて書いたほうがよい場合もあります。
let と const で推論される型が変わる
型推論でつまずきやすいのが、let と const で推論結果が変わる点です。同じ値で初期化しても、宣言のしかたによって推論される型が異なります。
const greeting = "hello"; // 型は "hello"(文字列リテラル型) let message = "hello"; // 型は string const count = 10; // 型は 10(数値リテラル型) let total = 10; // 型は number
const で宣言した変数は再代入できないため、値が変わることはありません。そのため TypeScript は、その値ぴったりのリテラル型("hello" や 10)に絞り込んで推論します。一方 let は再代入できるので、あとで別の文字列や数値が入る可能性を考慮し、string や number といった広い型に推論されます。この「リテラル型から string などの広い型へ広げること」を型の拡大(widening)と呼びます。
次の表に、代表的な初期値に対する const と let の推論結果をまとめます。
| 初期値 | const での推論 | let での推論 |
|---|---|---|
"hello" | "hello" | string |
10 | 10 | number |
true | true | boolean |
この違いは、リテラル型を要求する場面で効いてきます。たとえば "GET" | "POST" のようなユニオン型を期待する関数に let の変数を渡すと、その変数が string に広がっているため代入できずエラーになることがあります。値を固定したいときは const を使う、というのが基本の考え方です。
文脈的型付け|コールバックの引数が推論される
ここまでは値から型を決める推論でしたが、TypeScript には逆方向の推論もあります。それが文脈的型付け(Contextual Typing)です。式が置かれている「文脈(どこで使われているか)」から、型が逆算して決まる仕組みです。もっともよく出会うのは、コールバック関数の引数です。
const names = ["Alice", "Bob", "Carol"];
// コールバックの引数 name に型注釈は書いていない
names.forEach((name) => {
// name は string と推論される(names が string[] だから)
console.log(name.toUpperCase());
});
window.addEventListener("click", (event) => {
// event は MouseEvent と推論される("click" の文脈から)
console.log(event.button);
});
names は string[] なので、その forEach に渡すコールバックの引数 name は自動的に string と推論されます。同じように、addEventListener に "click" を渡すと、コールバックの event は MouseEvent だと文脈から判断されます。関数がどこで呼ばれるか、その関数の型が分かっているからこそ、引数の型を書かずに済むわけです。文脈的型付けが効いている場面では、コールバックの引数にわざわざ型注釈を付ける必要はありません。
複数の値から型を決める best common type
配列リテラルのように複数の値をまとめて推論するときは、それぞれの要素の型を見て、全体に共通する最適な型を選びます。これを best common type と呼びます。要素の型がそろっていればその型の配列に、異なる型が混ざっていればそれらのユニオン型の配列になります。
const numbers = [1, 2, 3]; // number[] const mixed = [1, "two", 3]; // (string | number)[] const flags = [true, false]; // boolean[] // null が混ざるとユニオンに含まれる const values = [1, 2, null]; // (number | null)[]
[1, "two", 3] は数値と文字列が混ざっているため、共通の型として string | number が選ばれ、配列全体は (string | number)[] と推論されます。要素がすべて数値なら number[] になります。複数の値をまとめたときの型が思ったものと違う場合は、この best common type がどう働いたかを考えると理解しやすくなります。
型注釈をあえて書くべき場面
型推論は強力ですが、すべてを推論に任せればよいわけではありません。むしろ、あえて型注釈を書いたほうが安全でわかりやすくなる場面があります。ここでは代表的なものを見ていきます。
関数の引数には型注釈を書く
関数の引数は、原則として型注釈を書きます。引数は関数の外から渡される値であり、文脈的型付けが効く場合を除いて、TypeScript が値を推論する手がかりを持たないためです。型注釈のない引数は、後述する暗黙の any になってしまいます。
// 引数に型注釈がある:安全
function greet(name: string) {
return `Hello, ${name}`;
}
// 引数に型注釈がない:name は暗黙の any になる
// (tsconfig の noImplicitAny が有効ならエラー)
function greetBad(name) {
return `Hello, ${name}`;
}
tsconfig.json で noImplicitAny を有効にしていれば、型注釈のない引数はエラーとして知らせてくれます。厳格な設定(strict)ではこれが有効になっているため、引数には型注釈を付けるのが基本になります。
公開する API の戻り値は明示する
ライブラリやモジュールとして外部に公開する関数では、戻り値の型を明示しておくと安全です。戻り値を推論に任せていると、関数の中身を少し変えただけで戻り値の型が意図せず変わり、それが利用側にまで波及してしまうことがあります。型注釈を書いておけば、実装を変えても「約束した型」から外れた瞬間にその関数自体がエラーになり、変更の影響をその場で食い止められます。
interface User {
id: number;
name: string;
}
// 戻り値の型を明示:この関数は必ず User を返すと約束する
function findUser(id: number): User {
return { id, name: "Alice" };
// もし name を書き忘れると、この関数の中でエラーになる
}
戻り値の型を書いておくと、実装ミスを利用側ではなく関数の内部で検出できます。エラーの発生場所が原因に近くなるため、デバッグもしやすくなります。
空配列の初期化に注意する
推論の落とし穴としてよく知られているのが、空配列の初期化です。const arr = [] のように要素のない配列を作ると、推論の手がかりがないため any[] になってしまいます。以降、どんな値を push しても型チェックが働かず、事実上 any と同じ状態になります。
// 型注釈なし:any[] になり、型チェックが効かない
const items = [];
items.push(1);
items.push("hello"); // エラーにならない(any[] のため)
// 型注釈を書く:意図した型の配列になる
const ids: number[] = [];
ids.push(1);
ids.push("hello"); // エラー:string は number に代入できない
空配列を作るときは、const ids: number[] = [] のように、あらかじめ要素の型を注釈で指定します。こうすれば、意図しない型の値が入るのを防げます。空配列に限らず、初期値だけでは正しい型が決まらない場面では、型注釈で意図を明示するのが安全です。
まとめ
型推論は、初期値や式、そして使われている文脈から TypeScript が型を自動で判断してくれる仕組みです。変数の初期値からの推論や関数の戻り値の推論のおかげで、型注釈を書かなくても型チェックが働きます。const は値ぴったりのリテラル型に絞り込み、let は widening によって string などの広い型になります。コールバックの引数は文脈的型付けで推論され、複数の値は best common type でまとめられます。一方で、関数の引数、公開する API の戻り値、そして const arr = [] のように any[] や暗黙の any に化けてしまう場面では、あえて型注釈を書くべきです。推論に任せる場面と、明示する場面を使い分けることが、読みやすく安全な TypeScript を書くコツです。