TypeScriptはJavaScriptに静的型を追加した言語で、コンパイルエラーを検出することでJavaScript開発をさらに快適・効率的にしてくれるものです。
型システムを備えている言語は、多かれ少なかれ何らかの形で型推論を備えています。大ざっぱに言えば、これは型を明示的に書かなくてもコンパイラがいい感じに型を推測して理解してくれる機能です。型推論は静的型付き言語における花型機能のひとつと言ってもよく、色々な言語がそれぞれの特色を持っています。
この記事ではTypeScriptにおける型推論について詳説します。この記事に書いてあることは、TypeScriptを普段から書いている方ならなんとなく理解している内容が多いと思います。しかし、これらの内容がちゃんと言語化されている記事がいまいち見当たらなかったので今回記事を書くことにしました。
※ この記事の内容は執筆時点の最新版のTypeScript (3.5 RC) に基づいています。TypeScriptの型推論は破壊的変更が比較的起こりやすい部分であり、最新版のTypeScriptではこの記事に書いてある通りの動作をしない可能性があります。
※ 解説の内容は--strict
オプションを指定した状態を前提としています。このオプションを指定していない場合の挙動はこの記事と異なるところが結構あります。
TypeScriptの型推論の概要
TypeScriptにおける型推論の基本は式の型の推論です。それに付随して、変数の型の推論や関数の型引数の推論、またオーバーロードの解決などが発生します。推論という言葉を聞き慣れない方もいるかもしれませんが、これは必要な情報を何らかの方法で推測し決定することです。推論というのは普通は当てずっぽうではなく、ちゃんと何らかの論理的な方法で決定されます。TypeScriptがどういう方法で型推論を行うのかというのがこの記事のテーマですね。
前提として、JavaScript (TypeScript)は文と式という基本的な概念を持ちます。この2つの違いは、式は評価結果の値があるという点です。
TypeScriptでは(もっと言えば、ほとんどの静的型付き言語では)全ての式に対してその式の型が決定されます。この処理こそが型推論の本体であると言ってもよいでしょう。
非常に簡単な例で説明します。
const a = 2 + 3;
このプログラムにおいて、変数a
の型はどうなるでしょうか。それは、a
に代入されている2 + 3
という式の型をTypeScriptがどう推論するかによって決められます。結論から言えば、a
の型はnumber
です。つまり、2 + 3
という式の評価結果はnumber
型の値、すなわち数値になるであろうとTypeScriptが推論したことになります。この推論は、「2
や3
は数値である」という事実と「+
で数値同士を足した結果は数値である」という2つの事実から裏付けられます。
また、次の例ではどうでしょうか。
const a = x + 3;
変数x
はあらかじめ定義されているとしましょう。この場合、a
の型、つまりx + 3
の型はx
の中身によって決まります。もしx
が"123"
という文字列だった場合、JavaScriptの+
演算子に文字列と数値を渡した場合は文字列の結合となりますから結果は"1233"
となります。つまり、x + 3
の型はstring
型です。一方で、もしx
が456
という数値だった場合、+
は数値の加算として働きますからx + 3
は459
となります。よって、x + 3
はnumber
型です。
この例から分かることは、式の型を推論するためにはその中に登場する変数の型が必要であるということです。x + 3
という式の型を推論するためにはまずx
の型が分からないといけないのです。このことから、宣言された全ての変数に対してその型を推論する需要が発生します。型推論のそのほかの要素(型引数の推論など)もその目的はすべて「式の型を推論する」ということに集約されます。
では、TypeScriptの型推論の詳細に踏み入っていくことにしましょう。
式の型の推論 (1)
とりあえず、TypeScriptがどのように式の型を推論するのかを見ていきます。ここから先はいろいろな型が登場しますが、もしよく分からない型が出てきたら以下の記事を見てみるのがよいでしょう。
プリミティブリテラルの型
JavaScript/TypeScriptプログラミングにおける最も基本的な要素はリテラルです。リテラルは何らかの値をプログラム上で表現する方法です。例えばabcde
という文字列を表したい場合は"abcde"
と書きますね。この"abcde"
がリテラル(この場合は文字列リテラル)です。リテラルはプリミティブ値(真偽値、数値、文字列など)を作るリテラルとオブジェクトを作るリテラルに大別できますが、まず前者を見ていきます。
一般的なプログラミング言語では、"abcde"
というリテラルは文字列を生み出すので、その型は文字列型(string
型)に推論されるでしょう。同様に、234
などの数値リテラルはnumber
型、true
やfalse
は真偽値リテラルなのでboolean
型に推論されるというのが一般的な挙動です。
しかし、TypeScriptはそれとは少し違う挙動をします。その理由はリテラル型の存在です。
結論としては、TypeScriptではリテラルの型は対応するリテラル型に推論されます。何を言っているのかよく分からない方のために、例を用いて解説していきます。
単純な例として、以下のプログラムで変数a
の型はどうなるでしょうか。この問題はつまり、"abcde"
というリテラルの型がどう推論されるかという問題と同じです。
const a = "abcde";
この場合は、a
の型は**"abcde"
型**になります。分かりにくいですが、string
という名前の型があるのと同様に"abcde"
という名前の型があるのです。この型は"abcde"
という文字列のみが許される型であり、文字列型(string
型)の部分型1です。
基本的に、型推論においてはより厳しい型が推論されるほうが(情報が増えるので)嬉しいです。a
が"abcde"
型を持つことによって、a
は"abcde"
という文字列であることが型システム上で保証されます。もしa
がstring
型だった場合は「a
は何らかの文字列である」という情報しか無いことにになり、余計な可能性が発生しているという点で情報が少ないと言えます。
これは文字列リテラルの例ですが、数値リテラル・真偽値リテラルについても同じです。例えば3
というリテラルの型は3
型であると推論され、true
はtrue
型であると推論されます。また、TypeScriptはnull
型(null
という値のみが存在する型)も持っており、null
というリテラルもnull
型に推論されます2。
組み込み演算子の型推論 (1)
型推論の挙動というのは、言語に存在する各構文がどのようなルールで型を推論されるかによって決まります。ここまで解説したのはプリミティブリテラルに対するルールでしたね(オブジェクトを作るリテラルもあるのですが、都合上それはすこし後回しにします)。
JavaScriptには多くの演算子(特に数値計算関係)がありますので、それに対する型推論を見てみましょう。
算術演算子
-
や*
などほとんどの数値演算は、まずそのオペランド(演算子の計算の対象となる値)が数値である、すなわちnumber
型かbigint
型である必要があります。そうでない場合は型エラーが発生します。ここまでの説明ではあまり強調してきませんでしたが、型がおかしい場合に型エラーを発生させるのはTypeScriptの型推論の目的のひとつでありたいへん重要です。
そして、オペランドがnumber
型なら結果の型もnumber
型となり、オペランドがbigint
型なら結果の型もbigint
型となります。これは-
などの演算子の実際の挙動と一致していますね。まあ、型推論が実際の挙動と一致していなかったら型の意味がないのですが。
ここまでの説明をまとめると次のようになります。
// a は number 型
const a = 123 - 45;
// b は bigint 型
const b = 56n - 789n;
// これは型エラー(オペランドが数値でないので)
const c = "1234" - {};
他に注意すべき点としては、オペランドの片方がnumber
で片方がbigint
の場合はエラーになるという点です。これは実際のJavaScriptで実行時エラーとなるためそれを防ぐ意味があります。
// これも型エラー
const d = 123n - 45;
また、たとえ結果があらかじめ分かっていたとしても算術演算子の結果がリテラル型になることはありません。上の変数a
の例だと、-
の左が123
型で右が45
型であることからa
に入るのは78
であるのは明らかですが、a
の型は78
型ではなくnumber
型です。なお、123
型や45
型はnumber
型の部分型であることに注意してください。これにより、上で説明した「両辺がnumber
型の場合」が適用されるのです。
ここまでは-
を用いて説明しましたが、他の算術演算子やビット演算もすべて同様です。
ちなみに、両辺がany
型で実際の結果がnumber
なのかbigint
なのか分からない場合は、演算子の結果はnumber
型に推論されます。もしany
型の値が実際にはBigIntだった場合に実行時の値が型推論と矛盾してしまいますが、まあany
を使用した時点で型安全性は既に消し飛んでいるので大した問題ではありませんね。
+
一方、+
だけは異なる挙動を示します。これは、+
が数値の加算だけでなく文字列の連結にも使われることが理由です。-
などと同様なのは、両オペランドがnumber
型だと結果がnumber
型となり、両辺がbigint
型だと結果がbigint
型になる点です。number
とbigint
型を組み合わせるのはやはりエラーです。
// a は number 型
const a = 123 + 456;
// b は bigint 型
const b = 1n + 2345n;
// これは型エラー
const c = 12 + 345n;
// こういうのも型エラー
const d = 123 + null;
その一方、どちらか片方のオペランドがstring
型だった場合は結果がstring
型になります。面白いのは、この場合はもう片方がどんな値だろうとエラーになりません(ただしsymbol
型のみ例外的にエラーとなります)。
// e は string 型
const e = "123" + "456";
// f も string 型
const f = "1234" + null;
// g も string 型
const g = {foo: 123} + "456";
// h も string 型
const h = "123" + function foo() { return 456; };
両辺とも文字列でない場合はこういう変な値を足すことは当然できません。
// これは型エラー
const i = {} + function foo() { return 456; };
また、+
のオペランドにany
型が来ている場合は、もう一方がstring
型であることが判明している場合は+
の結果がstring
となり、そうでない場合は+
の結果もany
となります。
単項演算子
単項演算子は数値用(+
, -
, ~
)、真偽値用(!
)、そしてインクリメント/デクリメント(++
、--
)の3種類に大別できます。
数値用の単項演算子は「null
やundefined
以外は何でも受け付ける」という特性を持ち、結果はbigint
型(オペランドがbigint
型の場合)かnumber
型(それ以外の場合)になります。この何でも受け付けるという特性は+
などが他の型から数値へ変換する目的で使われることがあるからでしょう。
// a は number 型
const a = +"123";
// b も number 型
const b = -"456";
// c も number 型
const c = -{};
// これは型エラー
const d = +null;
// e は number 型
const e = +(3n as any);
ちなみに、負の数のリテラル型に対応できるようにだと思いますが、+
や-
の結果はリテラル型になることがあります。
// f は 123 型
const f = +123;
// g は number 型
const g = +(+123);
// h は -456 型
const h = -456;
// i は number 型
const i = -(456);
次に!
に関しては、オペランドとしてnull
やundefined
型、unknown
型を含めてどんな値でも受け付けることができます(これはif
文の条件部分など、真偽値が期待される場面における共通の挙動です)。例外はvoid
型であり、この型の値が与えられたときのみ型エラーとなります。
!
演算子の結果の値は基本的には常にboolean
型ですが、型情報から結果が明らかな場合はtrue
型かfalse
型が生成されることがあります。
const num: number = 123;
// a は boolean 型
const a = !num;
// b は true 型
const b = !0;
// c は false 型
const c = !123;
// d は boolean 型
const d = !(123 as unknown);
// e も boolean 型
const e = !(0 as any);
// f は false 型
const f = !{foo: 123};
// これは型エラー
const g = !(()=>{}());
インクリメント・デクリメントは算術二項演算子と同じ感じの処理になります。
他にも&&
や||
や? :
などの説明が残っていますが、説明の都合上後回しにします。
変数の型の推論
このあたりで変数の型の推論を解説します。JavaScriptではいくつかの方法で変数を作ることができますが、その中でも型推論が重要になるのが変数宣言(var
, let
, const
)で変数を作る場合です。利便性のために変数宣言で変数を作るときに型註釈を省略できるようになっており、その場合はTypeScriptがその変数の型を推論して自動的に決めてくれます。
変数の型推論における基本的な原則は、変数の型は宣言時に決まる(変数が宣言後にどのように使われるかは型推論において考慮されない)こと、そして一度決まった変数の型は変わらないということです。これが原則ですが、実はこのあといくつか例外が出てきます(後で詳説しますがフロー情報を用いた推論がされる場合です)。それでも、宣言時に型が決まるというのはTypeScriptの基本的な言語デザインですから、そう思っておくのは悪いことではありません。
const
の推論
const
の場合が一番話が簡単なので、まずそれを解説します。一応説明しておくと、JavaScriptにおいてconst
で作られた変数は再代入が不可能となります。変数の中に入っているオブジェクトをいじることに関しては制限はありません。その性質から、const
で宣言する場合は必ずconst 変数 = 式;
という形で変数の中身を書く必要があります。const 変数;
のように宣言だけしておいてあとから代入することはできません。
話は簡単で、その変数に代入されている式の型推論結果が変数の型となります。
// a は 123 型
const a = 123;
// b は number 型
const b = a + 456;
この例では、変数a
に代入されているのは123
という式で、この式の型推論結果は123
型です。よって、a
の型は123
型となります。また、変数b
に代入されているa + 456
という式の型推論結果はnumber
型です。よって、b
の型はnumber
型となるのです。
型註釈がある場合
TypeScriptでは、変数宣言はこのように変数の型を型註釈として明示的に宣言することができます。
// 変数 a の型は number 型になる
const a: number = 3;
// これはエラー(宣言に反する値を代入しているので)
const b: number = "foo" + "bar";
このように変数の型が宣言されている場合、TypeScriptはその変数に代入されている値が正しいかどうかを型検査します。すなわち、=
の右の式の型を推論して、その型が変数の型に代入可能かを調べます。上の変数a
の例では=
の右は3
という式であり、この式の型は3
型と推論されます。3
型はnumber
型の部分型なので、3
型の式は変数a
に代入することができます。よって、変数a
の宣言ではエラーは起きませんでした。一方の変数b
の場合、式"foo" + "bar"
の型を推論するとstring
型になります。string
型はnumber
型に代入可能ではありませんから、ここで型エラーが発生します。
また、変数の型は原則として型註釈がそのまま採用されます。というのも、上の例の変数a
などは型註釈がnumber
であるとはいえ実際は3
であることが分かっているので、3
型に変えてしまう手もありそうですね。しかし、TypeScriptが勝手にそのような変更を加えることはありません。
なお、もう慣れてきたかと思いますが、“原則として”ということはやはり例外もあります。それは型註釈がunion型の場合ですが、これについては詳しくは後述します。
var
やlet
の推論
var
とlet
も、型推論上の基本的な扱いはほぼ同様です。この2つの特徴はconst
とは異なり再代入があることです。つまり、最初に宣言された後も他の値が変数に代入される可能性があります。
とはいえ、基本はconst
の場合と同じで、宣言時に代入された式の型が変数の型となります。とりあえずこの例を見てください。
// a の型は number 型
var a = 123 - 456;
// b の型は string 型
let b = "foo" + "bar";
123 - 456
という式の型はnumber
型なので、変数a
の型はnumber
型となります。同様に変数b
の型はstring
型となります。これはconst
の場合と同じで分かりやすいですね。
リテラル型のwidening
お察しとは想いますが、上の例ではわざと簡単な式を避けていました。次のような場合、var
やlet
の結果はconst
とは異なるものになります。
// a の型は number 型
var a = 123;
// b の型は string 型
let b = "foobar";
// a は number 型なので別の数値を代入可能
a = 456;
// b も同様
b = "aiu";
const
とは異なり、var
やlet
に代入された式がリテラル型の場合はそれらはリテラル型ではない型に変換されます。上の例では、123
型はnumber
型に、"foobar"
型はstring
型に変換されました。このように、リテラル型からそれに対応するプリミティブ型に変換される挙動はwidening(型の拡張?)と呼ばれます。
このwideningによって、変数に別の値を再代入することができるようになっています。もし変数a
の型がnumber
型ではなく123
型に推論されていたら、a = 456
は型エラーとなってしまい大変不便です(456
型の値は123
型に当てはまらないため)。
型註釈がある場合
var
やlet
の場合でも変数宣言に型註釈をつけることが可能です。この場合の挙動はconst
と同じで、変数の型はその型註釈が尊重されます。もちろんこれはwideningよりも優先されますから、次のようなことが可能です。
// 変数 a は 123 型
let a: 123 = 123;
a = 456; // 456 型の値を 123 型の変数に代入できないのでエラー
var
やlet
に対する型註釈は、さらに広い型を変数につけたい場合も有用です。例えば数値も文字列も入れることができる変数を作りたい場合は、number | string
型の変数を作るとよいでしょう。この|
を用いた記法はunion型の記法で、これはnumber
型でもstring
型でもいいということになります。
let a: number | string = 456;
a = "foo";
宣言時に初期化しない場合(型註釈あり)
JavaScriptでは、var
やlet
で変数を宣言だけして宣言時に何も代入しないことも可能です。TypeScriptもこれをサポートしています。
// number 型の変数 a を宣言だけする
let a: number;
if (Math.random() < 0.5) {
a = 123;
} else {
a = 0;
}
console.log(a);
この場合、型註釈にある通り変数a
の型はnumber
型です。a
は宣言された直後のif
文の中で数値が代入されますから、実際a
には数値が入っていることになりこれは正しいですね。
しかし、a
に何も入っていない所が一瞬あってちょっと怪しい感じがします。JavaScriptでは宣言だけして何も代入していない変数はundefined
が入っていますから、if
文の前ではa
にundefined
が入っていて型に従っていないような気がしますね。
TypeScriptはこの問題にちゃんと対処してくれる機構を持っています。それは、「宣言後に初期化されていない変数を使おうとしたらエラーを出す」という機構です。この例ではif
文の前にa
を使おうとするとエラーになります。
// number 型の変数 a を宣言だけする
let a: number;
console.log(a); // これはエラー(a に値を代入する前に使用しているので)
if (Math.random() < 0.5) {
a = 123;
} else {
a = 0;
}
console.log(a);
また、a
に値が代入されていない可能性があるだけでもやはりエラーです。
// number 型の変数 a を宣言だけする
let a: number;
if (Math.random() < 0.5) {
a = 123;
}
// これはエラー(aに何も代入されていない可能性があるので)
console.log(a);
ちなみに、変数a
の型をundefined
を許すように宣言することで、何も代入しないで使うことができます。気がきく仕様ですね。
let a: number | undefined;
if (Math.random() < 0.5) {
a = 123;
}
// エラーにならずにaを使える
console.log(a);
この機構により安心して変数を初期化無しで宣言することができます。コードの可読性の観点からは、初期化無しで変数を宣言するときは型を明示した方が良いですね。
しかし実は、型註釈も無し、初期化も無しという宣言も許されています。その場合、TypeScriptが急に本気を出します。それが次の話題です。
宣言時に初期化しない場合(型註釈なし)
var
やlet
が型註釈無し・初期値無しで宣言されている場合、その変数に対しては代入されるとそれに応じて型が変わるという挙動が有効になります。これは先に説明した「変数の型は宣言時に決まる」の例外となる挙動ですが、主にJSの型チェックにおいて必要となるため実装されています。
ただ、先に述べておきますが、この機能は微妙に危険なので新たにTypeScriptコードを描く場合は利用を避けるべきです。ちゃんと型註釈くらい書きましょう。
では例に移ります。
let a;
// ここでは a は undefined 型
console.log(a);
const u: undefined = a;
if (Math.random() < 0.5) {
a = 123;
} else {
a = 0;
}
// ここで a は number 型
const num: number = a;
console.log(a);
このコードでは変数a
を宣言したあと、いきなりa
を使用しています。これはエラーにはならず、ここではa
はundefined
型の変数として扱われます。a
は宣言された後に触られていないため、初期値のundefined
が入っていると判断されてundefined
型になるのです。
次がポイントです。次のif
文ではa
に数値が代入されていますが、これによりその後でa
がnumber
型に変化しています。
このように、型註釈無し・初期値無しで宣言された変数は現在の型と異なる値を代入することができ、それに合わせて変数の型が変わるのです。
次の例のように、必要に応じてunion型が発生することもあります。
let a;
if (Math.random() < 0.5) {
a = 123;
}
// a は number | undefined 型
console.log(a);
この挙動は便利に見えますが、変数の型がころころと変わる(=変数が複数の意味を持つ)のはあまり良いことではありません。初期値をちゃんと指定するか、それができない場合は型を明示するのが良いでしょう。このどちらかをすることで、後から異なる型を代入するのは型エラーとなります。
さらに、この機能に関してはTypeScriptが推論できる範囲にも限界があります。特に関数呼び出しが関わる場合が問題で、この場合は誤った推論結果となる可能性があります。
let a;
// ここでは a は undefined 型
console.log(a);
const u: undefined = a;
change_a();
// ここで a は undefined 型
const num: undefined = a;
console.log(a); // "123" が表示される
function change_a() {
a = "123";
}
change_a()
関数はlet
で宣言された変数a
に文字列を代入しています。しかし、change_a()
を呼び出す側のコードではその事実が無視され、change_a()
を呼び出した後も変数a
の型はundefined
型となっています。TypeScriptが関数呼び出しの中までは見に行かない上になぜかその場合も自信を失わないため、このような誤った推論が発生してしまいます。
これは安全性と利便性を天秤にかけた結果このような挙動になっているのだと思いますが、危険なことには変わりませんからあまり積極的に使うべきではありません。
まあ、実際のところ宣言される変数のほとんどはconst
であり、let
で変数を宣言することは多くありません。そのレアケースを突破した上にさらにその変数を関数の中から書き換えるというシェフの気まぐれパスタみたいなコードが生み出されることは稀でしょうから、頭の片隅に置いておけば大丈夫だと思います。
余談:--noImplicitAny
が無しの場合の挙動
上記の「型註釈も初期値もない変数」の型が代入された値に応じて変化するという挙動は、実は--noImplicitAny
コンパイラオプションが有効のときの挙動です。もしこの--noImplicitAny
オプションが無い場合は話はもっと単純で、そのような変数の型はany
型となります。
どっちにせよ危険な挙動ですから、やらなければ問題ありませんね。
余談2:型註釈がunion型の場合の挙動
型註釈ありで変数を宣言して、その型註釈がunion型だった場合の挙動は少し特殊です。個人的にはこの挙動は余計なお世話に思えますが、知らないとたまに困りそうなので解説しておきます。
変数の型註釈がunion型の場合で実際の中身がその中のどれに当てはまるのか明らかな場合、註釈を無視して変数の型がそれに固定されます。言葉ではいまいち伝わりにくいと思いますので例を見てください。
const stringOrNull: string | null = "abcde";
// stringOrNull は string | null 型ではなく string 型になる
// よって length プロパティを見たりしてもエラーが起きない
console.log(stringOrNull.length);
この例では、変数stringOrNull
にstring | null
型という型註釈を付けたにも関わらず、実際の型はstring
型になっています。まあ確かに、変数の宣言を見れば実際はstringOrNull
にnull
が入ったりしないのは一目瞭然ですから、この挙動は分からないでもありません。個人的には型註釈を勝手に無視しないで欲しいですが。
また、let
(やvar
)で宣言した場合も同様の事象が発生します。
let stringOrNull: string | null = "abcde";
// ここでは stringOrNull は string 型
console.log(stringOrNull.length);
しかもややこしいことに、この場合はstring | null
という型註釈が完全に死んだわけではありません。stringOrNull
は最初はstring
型ですが、これにnull
型の値やstring | null
型の値を代入したりできるのです。
let stringOrNull: string | null = "abcde";
// stringOrNull は string | null 型ではなく string 型になる
// よって length プロパティを見たりしてもエラーが起きない
console.log(stringOrNull.length);
// stringOrNull は string 型だが実は null を代入してもよい
stringOrNull = null;
// ここでは stringOrNull の型は null に変わっているのでこれはエラー
console.log(stringOrNull.length)
// 文字列を代入すると string 型に戻せる
stringOrNull = "foobar";
// これはOKになる
console.log(stringOrNull.length);
// string | null に当てはまらないものは代入できない
// のでこれはエラー
stringOrNull = 123;
このように変数に代入すると型が変わるという挙動は先程説明した「let
で型註釈も初期値もない場合」と同じですね。今回は型註釈を何も付けない代わりにunion型がついており、その結果代入に制限がかかるようになりました。ある意味で、変数が「読み出し時の型」と「代入時の型」を2つを持っているようなイメージです。実は先程の場合は「代入時の型」がany
であり何でも書き込み可能という扱いになっています。
そして、例は省略しますがこの場合も「関数を呼び出してその中で変数を書き換える場合は安全性が壊れる」という問題がそのまま残っています。前の場合はlet
に型註釈をつけないことが条件だったのでやる機会が少なそうでしたが、let
の型註釈にunion型を書くというのはわりと機会がありそうです。そんなコードを見かけたら警戒レベルを1段階上げたほうがいいかもしれません。
式の型の推論 (2)
さて、結構長いこと説明してきましたが、ここまで登場した型はstring
型などのプリミティブ型や"foo"
などのリテラル型だけでいまいち面白くないですね。ここからはやっとオブジェクト型が登場します。
一瞬だけ復習しましょう。オブジェクト型というのは次のような記法を持つ型です。
// number 型のプロパティ foo と string 型のプロパティ bar を持つオブジェクトの型
{
foo: string;
bar: number;
}
このようにオブジェクト型の中にはプロパティ名: 型
というペアを書くことができます。この他にもインデックスシグネチャや関数シグネチャなどもありますが詳細は型入門に譲ります。
JavaScript・TypeScriptにあまり馴染みがない方のためにちょっと補足しておくと、オブジェクト型において「型名」はあまり重要ではありません。これはクラスベースのオブジェクト指向言語とは対照的です。
クラスベースオブジェクト指向言語の多くは、オブジェクトは「クラス」から作られます。これに伴い、オブジェクトの型はクラスの名前によって表現されます。例えばclass Person {}
というクラスがあった場合、new Person()
という式はPerson
型になります。
しかし、TypeScriptのオブジェクトはクラスとは毛色が違います。オブジェクト型という概念はクラスとは何の関係もなく、例えば{ foo: string; bar: number; }
自体がオブジェクトの型となります3。次の例を通してこのことを理解しましょう。
type Hoge = {
foo: string;
bar: number;
}
type Fuga = {
bar: number;
foo: string;
}
// obj は Hoge 型
const obj: Hoge = {
foo: "uwaaaa",
bar: -5,
};
// obj はFuga 型でもある
const obj2: Fuga = obj;
ここで登場したtype
文は型に別名をつけることができる文です。ここではHoge
型は{ foo: string; bar: number; }
型の別名となり、Fuga
型は{ bar: number; foo: string; }
型の別名となります。
ここで気づいていただきたいことは、Hoge
型とFuga
型は同じ型であるということです。この2つはどちらも{ foo: string; bar: number; }
型を指しています(プロパティの順番が違うのは関係ありません)。
Hoge
とFuga
は全く同じ型です。型に別々の名前が付いているとかそういうことは全く関係ありません。よって、Hoge
型の変数obj
は当然Fuga
型として扱うことができます(逆も同じ)。
これからオブジェクト型の話をするにあたって、型にどんな名前が付いているかは関係ないという事実はぜひ覚えておきましょう。当然ながら、このようにオブジェクト型に名前をつけずに使うことも可能です。この例では{ foo: string; bar: number; }
型を変数obj
の型註釈として用いています。
const obj: { foo: string; bar: number; } = {
foo: "uwaaaaaaaa",
bar: -8,
}:
オブジェクトリテラルの型の推論
JavaScript・TypeScriptにおいてオブジェクトを作る主要な方法はオブジェクトリテラルを使うことです。オブジェクトリテラルも当然ひとつの式です。
const obj = {
foo: "hello",
bar: 123,
};
この例において変数obj
の型はどうなるでしょうか。それはもちろん、{ foo: "hello", bar: 123 }
という式の型がどのように推論されるかによって決まります。
実はこの場合、この式の型は{ foo: string; bar: number }
型となります。このように、オブジェクトリテラルからはその形に基づいたオブジェクト型が推論されます。この推論は以下のルールで行われます。
- オブジェクトリテラルに対してはオブジェクト型(これを
R
とします)が推論されます。 - オブジェクトリテラルが持つ各プロパティ
prop: expr
に対して、推論結果のオブジェクト型R
にもそのプロパティprop
が追加されます。 -
R
のプロパティprop
の型は式expr
の型推論結果となります。ただし、この型はwideningされます。
先ほどの例に沿って確認しましょう。{ foo: "hello", bar: 123 }
というオブジェクトリテラルはfoo
とbar
という2つのプロパティを持ちますので、結果のオブジェクト型もfoo
とbar
という2つのプロパティを持った型となります。
オブジェクトリテラルにおけるfoo
プロパティの中身は"hello"
という式であり、この式に対する型推論の結果は"hello"
型です。これがwideningされてstring
型となるため結果のオブジェクト型のfoo
プロパティはstring
型となります。bar
プロパティも同様に、123
型がwideningされてnumber
型となります。これにより、このオブジェクトリテラルに対する型推論の結果は{ foo: string; bar: number; }
型となります。
プロパティの型がwideningされる理由は、var
やlet
の場合と同じです。後から中身を変更できるようにということですね。JavaScriptのオブジェクトは(Object.freeze
などを使って変更不可にしない限り)プロパティを後から書き換えることができますから、その需要に対応できるようにこの仕様になっています。wideningが嫌な場合はlet
のときと同様に型註釈を明示的に書くか、あるいは後述のas const
を使いましょう。
配列リテラルの場合
JavaScriptでは配列を作るときに配列リテラルを使用できます。当然これに対しても型推論が働き、推論結果は配列型となります。配列型はnumber[]
のように、要素の型が何かという情報を持っています。number[]
は全ての要素がnumber
型である配列です。
配列リテラルの型推論は、それに含まれる各要素に対して型推論を行い、それに対してwideningを行ったものが要素の型となります。例えば、下の例の配列arr
は各要素に対する型推論の結果(1
型、234
型、-56
型)をwideningした結果であるnumber
を要素とする配列の型、すなわちnumber[]
型となります。
// arr の型は number[] 型
const arr = [1, 234, -56];
arr.push(789); // 数値を追加可能
arr.push("foobar"); // これはエラー
では、[1, "foo", -56]
のように複数の型が入り混じった配列リテラルを書いた場合はどうなるでしょうか。この場合は、各要素の型のunion型が取られます。
// arr2 の型は (number | string)[] 型
const arr2 = [1, "foo", -56];
// 任意の数値や文字列を追加可能
arr2.push(789);
arr2.push("foobar");
例によって、これだと困る場合は型註釈の出番です。例えば、配列の要素として数値はOKだけど文字列は"foo"
のみOKという場合はこんな型を書きましょう。
const arr: (number | "foo")[] = [1, "foo", -56];
arr.push(789); // これはOK
arr.push("foobar"); // これはエラー
as const
による推論の変化
TypeScript 3.4からはas const
という構文が導入されています。これはリテラル(各種プリミティブのリテラル・オブジェクトリテラル・配列リテラル)に後置で付加することができるものです(他の構文にも付加可能ですが意味はありません)。as const
が付加されたリテラルは、型推論の挙動が変化します。具体的には以下のような変化があります。
- リテラル型やオブジェクトのプロパティのwideningが発生しなくなります。
- オブジェクトリテラルに対する型推論の結果は各プロパティが
readonly
になります。 - 配列リテラルに対する型推論の結果は
readonly
タプル型となります。
また、この効果はas const
が付加されたリテラルそのものだけでなく、その中にあるリテラルに対しても適用されます。つまり、ネストしたオブジェクト・配列リテラルに対してas const
を付加した場合、その中の全てのリテラルがas const
の適用対象となります。
as const
の例はこんな感じです。
// 変数 a の型は string ではなく "foo"
let a = "foo" as const;
// 変数 b の型は { readonly foo: "foobar" }
const b = {
foo: "foobar"
} as const;
// 変数 c の型は readonly ["foo", 123]
const c = ["foo", 123] as const;
基本的にas const
を使用する主な目的はwideningを防ぐこととタプル型を簡単に作ることにあります。プロパティがreadonly
になる効果もありますが、そのために使う場面はそんなに多くないと思います。
論理演算子の型推論
&&
と||
は論理演算子として知られていますが、返り値が常に真偽値であるとは限らないという特徴を持ちます。これらの演算子は常にオペランドのどちらかを返します。a && b
という式の場合、a
を真偽値に変換してtrue
の場合はb
が、そうでない場合はa
を返します。一方a || b
という式の場合、a
を真偽値に変換してtrue
ならばa
が、そうでない場合はb
を返します。
これを踏まえてa && b
やa || b
の型推論の結果を考えてみましょう。ここで重要なのはこの式の評価結果として「a
とb
のどちらかが帰ってくる」という事実です。ここから考えると、これらの型の型推論結果は(aの型) | (bの型)
というunion型になりそうですね。
これは基本的には正しいです。例えばこんな例で確かめられます。
const num: number = 123;
// or の型は number | "foobar" 型
const or = num || "foobar";
変数num
がnumber
型であることを踏まえて考えると、num || "foobar"
は、num
が0
またはNaN
の場合は"foobar"
を返し、それ以外の場合はnum
を返します。よって、num || "foobar"
の型はnumber | "foobar"
型となります。
余談ですが、const
をlet
に変えると変数or
の型はnumber | string
に変わります。union型の中にもwideningが働くわけですね。
では、この場合も考えてみましょう。
const and = num && "foobar";
さっきと同様にこの場合のand
の型はnumber | "foobar"
となりそうですが、実は少し違います。この場合、and
の型は0 | "foobar"
型となります。この背景には先ほども述べたとおりnum && "foobar"
がnum
を返すのはnum
を真偽値に変換した結果がfalse
になる場合のみであり、具体的には0
とNaN
しかないことがあります。num
はnumber
型といっても実際に数値が返ってくるのは0
かNaN
だけなのでTypeScriptは親切にも型を絞り込んでくれるのです。
残念なことに、TypeScriptはNaN
を知らない(正確にはNaN
のリテラル型を持たない)という問題があります。今の説明だと型が0 | NaN | "foobar"
などにならないといけませんが、実際はNaN
の可能性は闇に葬られています4。厳密な安全性を求めるならこれは危険な挙動なのですが、そもそもNaN
がロジックに組み込まれている時点で異常事態なのでまあ大目に見てあげましょう。
とにかく、NaN
の可能性を忘れるならばこれは良い挙動ですね。上の例の変数and
に入る可能性がある数値は0
(とNaN)だけであることが型情報に現れています。
ほかに論理演算子がよく使われる場面はnull
かもしれない値を扱う場合です。ある変数にオブジェクトが入っているかもしれないしnull
が入っているかもしれないというような場合に||
を使うことでnull
だった場合のデフォルト値を指定できます。この場合も型がいい感じに推論されます。
type MyObj = { foo: string };
const objOrNull: MyObj | null = null;
// obj は MyObj 型
const obj = objOrNull || { foo: "" };
この例で||
の左辺はobjOrNull
、すなわちMyObj | null
型の値ですが、もしnull
が入っている場合は||
の右側の値が返る(objOrNull
が返らない)ため、型推論の結果からnull
が排除されています。
このように、||
や&&
は結構直感通りに型推論を行ってくれて便利です。
その上、実はこいつらはさらに強力な推論能力を秘めています。それが次の話題です。
条件分岐による型の絞り込み
たまには天下り的に例を示してみます。次の例を見てください。
type MyObj = { foo: string; };
let objOrNull: MyObj | null;
if (Math.random() < 0.5) {
objOrNull = { foo: "123" };
} else {
objOrNull = null;
}
// fooの型は string | null
const foo = objOrNull && objOrNull.foo;
ポイントは一番下のobjOrNull && objOrNull.foo
という式です。objOrNull
がMyObj | null
型であることを鑑みるといきなりobjOrNull.foo
とするのは型エラーとなるのですが(null
の可能性がある変数に対してプロパティアクセスすることはできないため)、objOrNull && objOrNull.foo
とすればエラーは起きません。この理由は、&&
の右側の式を型推論するときはobjOrNull
の型がMyObj | null
からMyObj
に変化しているからです。
このように、TypeScriptは特定の条件において変数の型が変化する挙動を持っています。この挙動は元の型よりも厳しい型(元の型の部分型)になることから型の絞り込み (type narrowing) と呼ばれています。
型の絞り込みが起きるのは条件分岐が発生した場合です。&&
は「左側の値を真偽値に変換したらtrue
のときのみ右側を評価する」ということですから、これもある種の条件分岐であると見なせます。&&
の右側では、「左側の値を真偽値に変換したらtrue
だった」という情報を使って良いわけです。
今回&&
の左側はobjOrNull
でその型はMyObj | null
でした。null
は真偽値に変換するとfalse
になる値ですから、&&
の右側では「objOrNull
がnull
である」という可能性は排除されています。ゆえに、&&
の右側ではobjOrNull
の型からnull
を除いてMyObj
となるのです。
このような挙動は&&
以外にも発生します。もちろん||
も可能ですし、条件演算子? :
も型の絞り込みをサポートしています。
さらに、条件分岐といえばif
文ですから、if
文も当然型の絞り込みができます。
type MyObj = { foo: string; };
let objOrNull: MyObj | null;
if (Math.random() < 0.5) {
objOrNull = { foo: "123" };
} else {
objOrNull = null;
}
if (objOrNull != null) {
// この中では objOrNull は MyObj
// なのでプロパティ foo を参照できる
console.log(objOrNull.foo);
}
また、条件についても色々なものがサポートされています。直前の例ではobjOrNull != null
という判定によってobjOrNull
がnull
である可能性を排除しました。他の例としては、typeof value === "string"
という判定によって変数value
の型がstring
以外である可能性を排除するといったことが可能です。
このような「if
文の中でだけ型が変わる」のような挙動はフロー解析と呼ばれることもあります。TypeScriptのフロー解析はreturn
文もサポートしているため、次のようなコードを書くこともできます。
type MyObj = { foo: string; };
function useMyObj(obj: MyObj | null) {
if (obj == null) {
// objがnullの場合は先にreturnする
return;
}
// ここではobjはMyObj型なのでfooを見ることができる
console.log(obj.foo);
}
TypeScriptではこのようなフロー解析を交えた型推論が便利で、特にunion型との相性が良い機能です。union型は型の表現力を大きく上げてくれるものですから積極的に使っていきましょう。
関数型の推論とcontextual types
この記事をここまで読んだ皆さんはだいぶ疲れているかと思いますが、まだ折り返し地点です。この記事で伝えたいことはまだまだあるのです。続きを読むのは明日とかでも大丈夫ですよ。(でもここまで読んでいいねと思ったらとりあえずいいねボタンを押していただけるとありがたいです。)
次のテーマは関数です。TypeScriptプログラミングにおいて関数は無くてはならない非常に頻出の概念であり、関数と型推論にまつわる色々な話題があります。
関数の型註釈・関数型
基本的な事実として、TypeScriptでは関数宣言時に引数と返り値の型の型註釈を書くことができます。型註釈を全部書いた場合はこんな感じの見た目になります。
function lengthOrDefault(str: string | null, defaultLength: number): number {
return str != null ? str.length : defaultLength;
}
ここで宣言されているlengthOrDefault
関数は引数が2つあり、1つ目の引数str
の型がstring | null
であること、2つ目の引数defaultLength
の型がnumber
であること、そして返り値がnumber
であることが型註釈によって明記されています。
また、関数宣言は変数を作ります。上の例の関数宣言は、関数オブジェクトが入ったlengthOrDefault
関数を作っていることになりますね。このような変数の型は関数型となります。具体的には、lengthOrDefault
の型は(str: string | null, defaultLength: number) => number
型ですね。ここまでは型推論とはそんなに関係ないただの関数型の復習でした。
なお、ここではfunction
による関数宣言の場合を示しましたが、function
式やアロー関数式なども同じです。
戻り値の型の推論
実は、関数の戻り値の型註釈を省略した場合は戻り値の型が推論されます。この推論は、当然ながら関数からreturn
されている式の型を推論することで行われます。複数のreturn
文がある場合は、どのreturn
文からも値が返される可能性があるということで、それぞれの返り値の型のunion型をとったものになります。具体例はこんな感じです。
// foo は (num: number) => "hoge" | "fuga" 型
function foo(num: number) {
if (num >= 0) {
return "hoge";
} else {
return "fuga";
}
}
この例ではreturn
文が2つあり、一方では返り値の型が"hoge"
型、もう一方では"fuga"
型ということで、それらのunionを取った"hoge" | "fuga"
型がfoo
の返り値の型となります。
また、return
文が無い関数の場合は返り値の型としてvoid
型が推論されます。一方、「return
文はあるけどreturn
せずに関数が終了する場合がある」という場合はreturn
しない部分はundefined
型を返すものとして推論されます。下の例の関数は、num >= 0
のときは"foo"
を返しますがそうでない場合は何もreturn
しません。JavaScriptではreturn
しない関数はundefined
を返した扱いになりますので、TypeScriptもそれに合わせる形で「この関数はundefined
を返す可能性がある」という推論を行い、それによりundefined
型とのunion型が生成されます。
// fooの返り値は "hoge" | undefined 型
function foo(num: number) {
if (num >= 0) {
return "hoge";
}
}
こういう時は勝手にundefined
を推論するんじゃなくてエラーにしてほしいという場合は--noImplicitReturns
オプションを使いましょう。これは--strict
に含まれていませんので別途指定が必要です。
引数の型の推論
このように関数の返り値の型は推論できましたが、引数の型についてはそうもいきません。次のように関数の引数の型註釈を省略した場合はエラーとなります(もし--noImplicitAny
を使用していない場合はエラーにはなりませんが、その場合は引数の型がany
に推論されます)。
// これは num に型註釈が無いのでエラー
function foo(num) {
if (num >= 0) {
return "hoge";
}
}
foo(123);
人間が見ればnum
の型はきっとnumber
型だろうなあという気持ちになりますが、TypeScriptはそういう推論はしてくれません。これは言語設計上の意思決定であり、TypeScriptの型推論アルゴリズムを簡単に(それでも型システム自体が複雑なせいでアルゴリズムも結構こんがらがっていますが)するためにこのような制限がかかっています。
まず、関数の中で引数がどう使われているかは見られません。今回はnum >= 0
とあるのでnum
がnumber
型のような気がしますが、その情報は使われません。これはもちろん、関数の中まで見て引数の型を決めると型推論が複雑化するし遅くなるからです。逆に、関数型言語では関数の中まで見て引数の型を推論するという挙動が多く見られますから、そういう挙動が好きな方は関数型言語をやるとよいでしょう。
また、関数の外にもfoo(123)
という呼び出しがありますが、これを見て引数num
の型が推論されるようなこともありません。これは「変数の型は宣言時に決まる」という原則により説明できます。それに、いちいちプログラム全体を探索してfoo
の呼び出しを探してからnum
の型を決定するというような挙動はパフォーマンス的に無理があります。TypeScriptでは基本的に“上から下”5にワンパスで型推論を行い前から順番に型を決めていくのです。
余談ですが、引数の型が推論されないというのは型推論時の話であり、TypeScriptが提供するquickfix(型エラーの修正をサジェストする機能)では実は「関数の使われ方を見て引数の型を自動で決める」という機能があります。例えばVSCodeではこれは以下のように使います。
引数の型エラーの部分でquickfixから「使用状況からパラメータの型を推論する」を選択します。
すると、foo(123)
からnum
の型が類推されてnumber
になりました。
これは、型検査に組み込むほど堅牢ではないけどエラーの修正を補助するには便利なので提供されていると考えられます。
contextual type による引数型の推論
話を戻しますが、とにかく関数の引数はちゃんと型註釈で型を宣言しなければならないというのがTypeScriptの基本的な考え方です。特に、関数の宣言そのものだけから型を決める必要があり、関数の中身とか関数の呼び出し側などそれ以外の部分の情報は使わないようになっています。
これを逆の視点から見ると、関数宣言そのものだけから型が決まりさえすれば無理に引数に型註釈を書かなくて良いということになります。これが、「関数の引数は型註釈を書かなければいけない」の例外となります。では、引数の型註釈がなくても引数の型が分かる場合というのはどういう場合でしょうか。
それは、contextual typeがある場合です。contextual typeを日本語で説明すると「文脈から分かる型」という感じでしょうか。今回も例で説明します。
type Func = (arg: number) => number;
const double: Func = function(num) {
// 引数 num の型は number 型と推論されている
return num * 2;
};
これは変数double
に代入されているfunction
式の引数num
に型註釈が無いにも関わらず、エラーになりません。その理由は、変数double
に型註釈Func
があるからです。この型註釈によって、「double
に代入される値はFunc
型でなければならない」という情報が生まれます。double
に代入されるのは=
の右側の値、すなわちfunction(num) { return num * 2; }
ですから、この式を型推論するときはfunction(num) { return num * 2; }
はFunc
型でなければならないという条件が与えられていることになります。このように、型推論の時点で期待される型があらかじめ分かっている場合をcontextual typeがあると言います。
contextual typeがある場合は式の型推論の結果がcontextual typeと矛盾しないかチェックすることになりますが、contextual typeの役目はそれだけではありません。そう、関数式の引数の型を推論するときにcontextual typeを使用することができるのです。
今回の場合、function(num) { return num * 2; }
の型はFunc
であるというcontextual typeがあります。Func
型というのは引数がnumber
型の関数ですから、それに合致させるためには当然引数num
はnumber
型でなければいけません。よって、引数num
の型がnumber
型と推論されるのです。
contextual typeはいくつかの場合に発生します。今しがた見たのは、変数の型註釈によって型推論中の式の型があらかじめ分かっている場合でした。これの応用として、ネストしたオブジェクトでも大丈夫です。
interface MyObj {
foo: (arg: number) => void;
}
const obj: MyObj = {
// ↓この num の型は number 型に推論される
foo: num => console.log(num * 2),
}
この場合は、obj
に代入されている{ foo: num => console.log(num * 2) }
のcontextual typeがMyObj
型です。よって、そのfoo
プロパティであるnum => console.log(num * 2)
という式のcontextual typeはMyObj
のfoo
プロパティの型、すなわち(arg: number) => void
となるのです。
関数引数のcontextual type
もう一つcontextual typeが発生する重要な例は、関数引数の場合です。
function callWith100(callback: (arg: number) => void) {
callback(100);
}
callWith100(num => {
// ここでは num は number 型
console.log(num * 10);
});
callWith100
は引数として(arg: number) => void
型の値を受け取る関数です。ということは、callWith100
を呼び出すときの引数の型は当然この型でなければいけません。ここでcontextual typeが発生しています。
つまり、num => { console.log(num * 10); }
は(arg: number) => void
というcontextual typeを伴って型推論されるため、引数num
がnumber
型と推論できるのです。
この挙動は便利ですが、一つ注意しなければいけない点がありますね。それは、一度コールバック関数を別の変数に入れようとすると上手くいかないということです。
function callWith100(callback: (arg: number) => void) {
callback(100);
}
// ↓これは num の型を推論できないのでエラー
const func = num => {
// ここでは num は number 型
console.log(num * 10);
};
callWith100(func);
この例ではcallWith100
の引数を一度変数func
に入れるように変更しただけですが、エラーが発生しています。これは、num => { console.log(num * 10); };
が作られた瞬間には変数に入れられるだけでその後func
がどう使われるか分からないためcontextual typeが無いということで説明できます。それくらい推論してくれてもいいじゃんと思わないでもないですが、過去にあったそのような提案は却下されています。
関数返り値のcontextual type
他にcontextual typeが発生する場面として、関数の返り値が型註釈で指定されている場合です。例はこういう感じですね。
type Func = (arg: number) => number;
function makeFunc(val: number): Func {
// ↓引数numはnumber型に推論される
return num => num + val;
}
この例では、関数makeFunc
の返り値の型がFunc
とされています。よって、return
文で返される式にはcontextual typeが働きます。これにより返される関数の引数num
の型を書かなくても良くなっているのです。
この3種類がとりあえず思いつくcontextual typeの出現ポイントです。他にもあるかもしれませんが、思いつかないので省略します。原則としては、まあ面倒な推論をしなくてもその場で型が明らかに分かりそうなときにcontextual typeがつきます。
関数呼び出しの型推論
TypeScriptの型推論において中々ややこしいことが起こりがちなのが関数呼び出しです。
とはいえ、普通の関数呼び出しは特に難しいことはありません。例えば下の例では関数func
を引数123
で呼び出しています。
const func = (num: number) => String(num * 2);
const str = func(123);
TypeScriptの「原則として関数の型は宣言時に決まる」という性質から、func(123)
という式を型推論する段階ではfunc
の型は判明済みのはずです。それゆえ、やるべきことはまずfunc
がちゃんと関数型かどうかをチェックし、引数の式(123
)の型を(contextual type付きで)型推論して、成功したらfunc(123)
という式の結果をすでに判明済みのfunc
の型の返り値部分にすればいいわけです。
しかし、話がそう簡単には進まない場合があります。ひとつは、型引数がある関数の場合です。
型引数がある関数の場合
型引数というのは、要するにジェネリクスのことです。次の例を使って説明することにします。
function apply<T, R>(value: T, func: (arg: T) => R): R {
return func(value);
}
// res は string 型
const res = apply(100, num => String(num ** 2));
関数apply
は2つの型引数T
, R
を持つものとして宣言されています。関数宣言時にはT
やR
はどんな型でもOK(extends
で制限を付けることできますが)なものとして扱われます。つまり、T
やR
が具体的にどんな型かは、apply
を呼び出すたびにその場で決められます。
型引数を明示する場合
T
やR
が何かを明示するために、呼び出し時に<>
で型引数を明示することができます。上の例の型引数を省略せずに書くとapply<number, string>(100, num => String(num ** 2))
となります。T
としてnumber
型を、R
としてstring
型を選んだということになります。型引数が明示されている場合はapply
の型が完全に判明しているので特に型推論で難しい点はありません。
型引数の推論
問題は、関数呼び出し時に型引数の指定を省略できる点です。この場合は、T
やR
といった型引数も一緒に推論されます。これがどのように行なわれるかというと、引数から推論されます。
例えば、apply
の型引数T
はどのように推論されるでしょうか。この場合は第1引数にヒントがあります。第1引数value
の型はT
ですが、呼び出し側ではこの引数に100
という値を渡しています。これにより、T
は100
を受け付ける型であることが分かり、number
型が推論されます。
ポイントは、関数の型を推論するのに引数の型が使用されているということです。引数の100
という式に対して先に型推論を行い、それによって型引数T
の値を決定することによって関数の型が判明するのです。
型引数の推論とcontextual typing
ところで、apply
の第2引数num => String(num ** 2)
は引数num
の型註釈を書いていませんが、型情報を見るとnum
の型はnumber
型と正しく推論されています。これはもちろんcontextual typingのおかげです。
しかし、よく考えるとおかしいのではないでしょうか。今回の場合、num
がnumber
型であると推論されるためには先に型引数T
の型がnumber
であると判明している必要があります。そのためには引数の型推論が既に終わっている必要があります。しかし、第2引数にはcontextual typeが必要であり、そのためには引数の型推論よりも先に型変数の推論ができていないといけません。引数の型推論と型引数の推論、一体どちらが先なのでしょうか。
この問題を解決するためにTypeScriptはひと工夫しています。それはcontextual typingが必要な引数だけ後回しにするというものです。つまり、apply(100, num => String(num ** 2))
という式の場合TypeScriptは次のステップを踏んで型推論を行います。
- contextual typingが不要な引数を先に型推論する。ここでは、引数
100
に対して型推論を行ってnumber
型を得る。 - 得られた情報から型引数を推論する。型変数
T
がnumber
に推論される。 - contextual typingが必要な引数を型推論する。引数
num => String(num ** 2)
に対して型推論を行って(num: number) => string
型を得る。このときcontextual typeは(arg: T) => R
型だが、T
はすでに判明しているので(arg: number) => R
となる。 - 再び型引数を推論する。型引数
R
がstring
に推論される。
このようなステップで型引数の推論とcontextual typingによる引数の型推論を両立しています。型引数T
とR
の推論結果が決まるタイミングが異なることに注目してください。
より正確に言えば、型引数の推論結果は、その型引数が使われた時点で確定します。よって、T
が最終的に確定する瞬間は実はステップ3内です。上記のようにcontextual typeは(arg: T) => R
ですが、これを使うと第2引数のnum => String(num ** 2)
の部分でnum
の引数がT
と推論されます。この時点でT
の型が具体的に何なのか知る必要が発生したため、T
の型が確定されます(これまでの情報からnumber
になります)。
一方、第2引数の型推論において型引数R
の情報は使われていません。それどころか、R
は推論される側ですね。ここではnum => String(num ** 2)
の返り値がstring
型であることからR
はstring
型という情報が得られています。この情報は得られていますが、R
の型を具体的に確定させる必要はこのタイミングではまだ発生していません。
R
の型が確定するのは、apply(100, num => String(num ** 2)
の返り値の型を決めるときです。宣言によればこの返り値の型はR
ですが、ここで初めてR
の型を具体的に知る必要が発生し、やっとR
がstring
に決定するのです。
型引数の推論が失敗する場合
以上の説明のように引数の型推論は2段階に分けて行われることがあります。しかし、これが3段階以上になったりすることはありません。したがって、次のような場合は型引数の推論に失敗します。
function apply<T, U, R>(
value: T,
func1: (arg: U) => R,
func2: (arg: T) => U,
): R {
return func1(func2(value));
}
function id<T>(value: T): T {
return value;
}
// 意図はT = number, U = string, R = boolean だが、
// 実際の型引数の推論結果は T = number, U = unknown, R = boolean となる
// その結果、str が unknown 型扱いになり str.length でエラーが発生
const res = apply(100, str => str.length > 0, num => String(num ** 2));
この例ではapply
に渡す関数が2つに増えています。なぜ型推論が失敗するのか詳しく見てみましょう。なお、引数の型推論は前から順番に行われます。
- contextual typingが不要な引数を先に推論する。
100
の型推論が行なわれてT = number
という情報が得られる。 - contextual typingが必要な引数を次に推論する。まず第2引数
str => str.length > 0
が推論される。このときcontextual typeとして(arg: U) => R
が使われるのでstr
の型はU
となる。まだU
の情報が無い状態でU
を具体化する必要が生じたので、U
はunknown
型になる。よって、関数内部の型推論中にstr.length
でエラーが発生(unknown
型の値に対してプロパティアクセスはできないので)。なお、エラーが発生しても一応型推論が続けられてstr => str.length > 0
の型は(str: unknown) => boolean
型になる。これによりR = boolean
という情報が得られる。 - 次に第3引数
num => String(num ** 2)
の型推論が行なわれる。今回のcontextual typeは(arg: T) => U
であり、num
の型はT
となる。ここでT
が確定してnumber
になる。
ポイントは、U
の正しい型が判明するのは第3引数の型推論を行った後なのに、それより前の段階でU
が必要になったためやむなくunknown
型に固定されているという点です。TypeScriptは原則としてワンパスで型推論を行うため、第3引数の推論で正しいU
が判明したからといって第2引数の推論をやり直すとかそういう挙動をすることはありません。
ちなみに、引数の順番を入れ替えて次のようにすると型推論が成功します。TypeScriptの型推論結果は処理の順番に依存するということですね。型引数の推論がうまくいかない場合はこのあたりの挙動を頭に入れておくと役に立つでしょう。
function apply2<T, U, R>(
value: T,
func1: (arg: T) => U,
func2: (arg: U) => R,
): R {
return func2(func1(value));
}
function id<T>(value: T): T {
return value;
}
// res は boolean 型
const res = apply2(100, num => String(num ** 2), str => str.length > 0);
オーバーロードされた関数の型推論
引数の型の推論結果が関数の型に影響する場面はもうひとつあります。それは関数がオーバーロードされている場合です。TypeScriptでは関数のオーバーロードという機構があり、これはひとつの関数が複数の関数型を同時に持つという機能です。
function func(arg1: number, arg2: number): number;
function func(arg1: string): string;
function func(arg1: number | string, arg2?: number): number | string {
if (typeof arg1 === "number") {
return arg1 + arg2!;
} else {
return arg1;
}
}
// res1 は number 型
const res1 = func(123, 456);
// res2 は string 型
const res2 = func("foo");
この例ではfunction func
という宣言が3つも並んでいて気持ち悪いですが、よく見ると最初の2つは関数の中身が書いていません。これがオーバーロードシグネチャの宣言です。これが2つあるので、関数func
は2種類の関数型を持っているという意味になります。3つ目が関数の実体の宣言です。
このように宣言されたfunc
を呼び出す側が今回のポイントです。2つの数値を引数に渡した場合はfunc
の1つ目のシグネチャ((arg1: number, arg2: number) => number
)が採用されて返り値がnumber
型と推論される一方で、1つの文字列を引数に渡した場合はもう1つのシグネチャ(arg1: string) => string
が採用されて返り値はstring
型になります。
これも型引数の場合と理屈は同じで、contextual typingが不要な引数の型を先に推論してfunc
のどのシグネチャを採用するか決定、その後でcontextual typingが必要な引数の推論を行うというのが基本的な流れです。
オーバーロードの解決が失敗する例
これを踏まえると、やはり型推論が失敗する例を作ることができます。例えばこんな感じです。
function func(callback1: (arg: number) => number, callback2: (arg: number) => string): string;
function func(callback1: (arg: string) => string, callback2: (arg: string) => number): number;
function func(callback1: any, callback2: any): string | number {
return callback2(callback1(100));
}
// 返り値の x で Type 'number' is not assignable to type 'string'. というエラーが発生
func(x => x, str => Number(str));
この関数func
は、2つ目のオーバーロードを選択すれば正しく型が付けられるはずです。型註釈x
もstr
もstring
型ということにすれば辻褄が合いますね。ところが、これらはどちらもcontextual typingが必要なのでfunc
のオーバーロードの解決が失敗し、x
とstr
はnumber
型に推論されます。ただその後のエラーを見ると、x => x
の返り値がstring
型であることが要求されており、どうやらオーバーロードの再解決が行われているようにも見えます。すみませんが、このあたりの挙動の詳細は筆者にもよくわかっておらず調査中です。
とにかく、実際にこんなオーバーロードがされた関数を書くことはあまり無いとは思いますが、オーバーロードと型引数を組み合わせたりしてややこしいことをすると実際のコードでもたまに型推論が失敗することがあります。基本的にはcontextual typingに頼っていることによる情報不足が原因ですので、適当に型註釈を増やしてやれば大丈夫なことが多いです。
まあ、筆者はそもそもオーバーロードがあまり好きではないのですが(関数定義の内部でだいたい敗北が発生するので)。
ジェネリクスとカリー化された関数に関する注意
関数型言語のバックグランドを持つ人は関数をカリー化したがることがあります。下の例のように、2つの引数を持つ関数をカリー化した場合は1引数関数を返す1引数関数になります。
// 元々の add 関数
const add_original = (left: number, right: number) => left + right;
// カリー化された add 関数
const add = (left: number) => (right: number) => left + right;
// add 関数の使い方
const eight = add(3)(5);
// 「3を足す関数」を作ったりできる
const add3 = add(3);
console.log(add3(10)); // 13
console.log(add3(10000)); // 10003
しかし、型引数を持つ関数をカリー化する場合は注意が必要です。次のようにするとうまくいきません。
// getは 配列 array の index 番目を返す関数
const get_original = <T>(index: number, array: T[]) => array[index];
const get = <T>(index: number) => (array: T[]) => array[index];
const numArr = [1, 2, 3];
// num1 は number 型
const num1 = get_original(1, numArr);
// num2 は number 型ではなく unknown 型になっている
const num2 = get(1)(numArr);
問題は、最後のnum2
の型がunknown
型になることです。これは、get
の型引数T
の推論結果がnumber
ではなくunknown
型になっているということを意味しています。つまり、T
の推論に失敗しているわけですね。
これは、型変数の推論はその関数が呼ばれた瞬間にのみ起こることを意味しています。関数get
というのは型引数T
を持ち、index
というひとつの引数を受け取って何らかの関数を返すものでした。ということは、型引数T
の推論はindex
というひとつの引数のみを使って行なわれます。端的に言えば、get_original
ではindex
とarray
という2つの引数を使ってT
を推論できたのに、get
では<T>
が直接ついている引数がindex
だけなのでindex
しか利用できないのです。
index
はT
とは無関係の引数なのでT
の情報は得られません。ということは、get
の返り値の型((array: T[]) => T
)を具体化する段階でT
の情報がないため、ここでT
がunknown
型に確定します。よって、get(1)
の型は(array: unknown[]) => unknown
となります。これがnum2
、すなわちget(1)(numArr)
の型がunknown
となった理由です。
今回の場合はget
を次のように変更すると意図した挙動に近くなるでしょう。型引数T
のつく位置をずらしてget
が型引数のある関数を返すように変更しました。これにより、T
の推論はarray
が渡されるまで遅延されます。
const get = (index: number) => <T>(array: T[]) => array[index];
// num2 に正しく number 型がつく
const num2 = get(1)(numArr);
その他の細かい挙動
ここまでで、TypeScriptの型推論のルール的なものはおおよそ説明し終わりました。実はまだいくつかの特殊ルールがあるので、ざっと解説していきます。
配列の型推論
配列を構築する場合、以下のようにまず[]
を代入してからpush
などを使って要素を追加していくことがありますね。実はこのとき、変数の型が宣言時に決まるというルールを逸脱した挙動が発生します。
const arr = [];
for (let i=0; i<10; i++) {
arr.push(String(i));
}
// ここでは arr の型は string[] 型
arr.push(123);
// ここでは arr の型は (string | number)[] 型
このように、arr
に対して行った操作に応じてarr
の型が変わっていくという、let
とかあの辺で見たような挙動をします。原理は同じです。
注意すべき点は、[]
に大して何も要素を追加せずに使用しようとするとarr
がany[]
扱いになることです(--noImplicitAny
下ではエラーとなります)。
const arr = [];
arr[0]; // ここでエラー
const arr2 = arr.slice(); //これもエラー
これが原因のエラーに遭遇することは滅多にありませんが、もしあったら「TypeScriptがバグった」などとは思わずに冷静にarr
に型註釈を追加しましょう。
const arr: string[] = [];
arr[0]; // OK
const arr2 = arr.slice(); // OK
高階関数の返り値がジェネリクスになる場合(型引数の受け継ぎ)
TypeScriptの原則の一つとして「型註釈が尊重される」というものがあります。すでにそれが尊重されない例外も紹介していますが、ここではもう一つそんな例を紹介します。それは「関数を受け取って関数を返す」ような関数に対する型推論の場合です。
まずは普通の例です。
function twice<T>(func: (arg: T) => T): (arg: T) => T {
return arg => func(func(arg));
}
const add1 = (arg: number) => arg + 1;
// add2 の型は (arg: number) => number
const add2 = twice(add1);
関数twice
は、T
型を受け取ってT
型を返す関数func
を受け取って、func
を2回適用する新しい関数を返します。上の例では、受け取った値に1を足して返す関数add1
をtwice
に渡すことで、受け取った値に2を足して返す関数add2
を得ています。
このtwice
呼び出しにおいて型推論はどのように行われているでしょうか。いきなり答えを言ってしまうと、T
がnumber
型に推論されます。これは、add1
に対して型推論が行われて(arg: number) => number
型を得て、それをtwice
の引数(arg: T) => T
と比較することでT
がnumber
であると結論しています。
型引数の受け継ぎが発生する例
では本題です。上の例を踏まえて次の例を見てください。
function twice<T>(func: (arg: T) => T): (arg: T) => T {
return arg => func(func(arg));
}
const id = <V>(arg: V) => arg;
// idid の型は <V>(arg: V) => V
const idid = twice(id);
今度は関数id
をtwice
に渡しています。先ほどとの違いはid
が型引数を持つことです。これに対応してtwice(id)
の型も<V>(arg: V) => V
となっていますね。
実はこの**twice(id)
の返り値の型こそが原則からの逸脱になっています**。というのも、twice
の関数宣言における返り値の型は(arg: T) => T
なのであって、型引数<V>
なんてどこにも書いていませんね。それにも関わらずtwice
にid
を渡したときの実際の返り値の型は<V>(arg: V) => V
になっています。
このような挙動は、特定条件下で引数として渡した関数が持つ型引数が返り値の関数に受け継がれるものとして説明できます。
その条件とは、その型引数の具体的な中身の推論が発生しなかったことです。また、他にも返り値の関数型がそれ自身ですでに型引数を持っている場合はだめです。
では詳細を見ていきましょう。twice(id)
という関数呼び出しに対する型推論を考えます。id
は関数なので、例によってcontextual typeありの型推論が行われます。今回のcontextual typeは(arg: T) => T
です。一方で、id
の型は宣言の通り<V>(arg: V) => V
です。これの辻褄を合わせるにはV = T
とすれば良さそうですね。
ここがポイントです。渡された引数id
が持っていた型引数V
は、T = V
という情報こそ得られたもののその具体的な型が何かは結局何も情報がないままです。これが「その型引数の具体的な中身の推論が発生しなかった」に相当します。
twice(id)
の返り値の型については、宣言では(arg: T) => T
でしたね。型引数の推論からT = V
が分かっているのでこれは(arg: V) => V
になります。さらに、上記の条件を全て満たしているので型引数の受け継ぎが発生し、最終的にこれは<V>(arg: V) => V
型となります。
ちなみに、この「型引数の受け継ぎ」はTypeScript 3.4で実装されました。TypeScript 3.3以前ではどうなっていたかというと、返り値の型が(arg: V) => V
となるところまでは同じですが、最終的にV
が具体化されます。V
については情報が無かったのでV
は{}
に置き換えられて、twice(id)
の返り値の型は(arg: {}) => {}
となっていました。
他の例
型引数の受け継ぎが発生する例をもう一つ見ましょう。今度はより変な例です。
function makeDouble<T>(func: (arg: T) => T): (arg: number) => number {
return num => num * 2;
}
const id = <V>(arg: V) => arg;
// fn の型は <V>(arg: number) => number
const fn = makeDouble(id);
makeDouble
は、引数でもらった関数は使わずに(arg: number) => number
型の変数を返すという謎の関数です。
これに対しても先ほどと同様の法則が適用された結果、V
が返り値の関数の型に受け継がれて<V>(arg: number) => number
型になります。面白いですね。
関数にプロパティを生やす時の型推論
「変数の型は宣言時に決まる」という原則の例外となる挙動をさらにひとつ紹介します。それは関数に後からプロパティを追加できるという挙動です。ちょっと長いですが例を出します。
// 関数を宣言
const func = (num: number) => num ** 2;
// 関数にプロパティを追加できる
func.foo = 123;
// メソッドも追加できる
func.method = (arg: number) => func(arg * 10);
/* func の型は
{
(num: number): number;
foo: number;
method(arg: number): number;
}
*/
useObj(func);
// もちろん関数として使える
func(100);
type MyObj = { foo: number };
function useObj(obj: MyObj) {
console.log(obj.foo);
}
この例では、関数func
を作った後にfoo
とかmethod
とかいうプロパティをfunc
に追加しています。普通はオブジェクトの型に存在しないプロパティを勝手に追加するのは型エラーですから、これは関数に独特の挙動ですね。
何でこんな変な機能があるのかとお思いかもしれませんが、JavaScriptでは意外と「関数にプロパティを生やして拡張する」ということをするコードが多いのです(特にCommonJSで関数をエクスポートしたいけど他の機能も提供したい場合など)。そのようなコードに対して昔は問答無用でエラーを吐いていて簡単な解決策もありませんでしたが、頻出なので切り捨てるわけにもいかないという判断からこうなったと考えられます。
機能の危険性
ただ、この機能にはひとつとても残念な点があります。関数の型を変化させるにあたって、let
とかとは違ってフロー情報を見ないのです。よって、実際にプロパティが追加される前から型上はプロパティが存在する扱いとなり、このようなプログラムをエラー無く書けてしまいます。
// 関数を宣言
const func = (num: number) => num ** 2;
// func にプロパティを追加する前に使用することができる!!!(危険)
useObj(func);
func.foo = 123;
func.method = (arg: number) => func(arg * 10);
type MyObj = { foo: number };
function useObj(obj: MyObj) {
console.log(obj.foo);
}
なので、安全性を気にする方はこの機能を使っているのを見たら黄色信号です。
安全な代替案
実は、型安全に同じことができる方法があります。それは次のようにObject.assign
を使う方法です。
const func = Object.assign(
(num: number) => num ** 2,
{
foo: 123,
method: (arg: number) => func(arg * 10)
}
);
useObj(func);
type MyObj = { foo: number };
function useObj(obj: MyObj) {
console.log(obj.foo);
}
この場合はfunc
は((num: number) => number) & { foo: number; method: (arg: number) => number }
型となり、型の見た目が違いますがまあ同じ意味です。この方法を使う場合は中途半端な状態のfunc
が露出しないので安全というわけです。
機能の有効化条件
この機能は「関数型なら何でもプロパティを追加できる」というものではありません。この機能が有効になるには一定の条件があります。
まず、この機能で拡張できる関数はfunction
宣言で作られた関数か、const
で変数に代入された関数でなければいけません。つまり、let
とかはだめです。
let func = (num: number) => num * 2;
// これはエラー(funcがletで宣言されているので)
func.foo = 123;
const func2 = (num: number) => num * 2;
// これはOK(funcはconstで宣言されているので)
func2.foo = 1234;
// これもOK(func3はfunction宣言で宣言されているので)
func3.foo = 123;
function func3(num: number) { return num * 100; }
なお、const
に関しては変数に“直に”代入された関数でなくてはいけません。つまり、const 変数 = 関数式;
の形でないとだめだということです。次の例はfunc
に入るのは確かに関数であるものの、直に関数式を入れるのではなく配列から取り出しているのでfunc
は拡張不可能になります。この“直に”という概念は関数のnameプロパティの話を思い出しますね。
const funcArr = [(num: number) => num * 10];
const func = funcArr[0];
// これはエラー(変数funcに直に関数式が代入されていないので)
func.foo = 123;
また、関数をconst
で宣言した場合でも、型註釈があるとだめです。次の例はfunc
に型註釈が付いているので拡張不可となります。
type F = (num: number) => number;
const func: F = num => num * 2;
// これはエラー(funcに型註釈があるので)
func.foo = 123;
let
の場合といい、変数に型註釈を付けないと特殊なフラグが立つのはちょっと気持ちが悪いような気がしないでもないですね。
型引数のwidening
ジェネリクスの説明に出てきたこの例を思い出してください。
function apply<T, R>(value: T, func: (arg: T) => R): R {
return func(value);
}
// res は string 型
const res = apply(100, num => String(num ** 2));
ここではT
型の引数に100
が渡されていたのでT
がnumber
型に推論されていました。よく考えると、ここでT
の型に対してwideningが発生していますね。100
という式の型は100
型なのにいつのまにかnumber
型にされています。
実は、型引数がいつでもwideningされるわけではありません。次の例ではT
はnumber
型ではなく100
型になります。つまり、こっちの例では型引数がwideningされていないのです。
function id<T>(value: T): T {
return value;
}
// num は 100 型
const num = id(100);
では、型引数はいつwideningされていつwideningされないのでしょうか。T
という型変数がある場合を考えましょう。実は、以下の条件のどちらかを満たせば型引数T
はwideningされません6。
-
T
にextends string
のようなプリミティブ型による制約(またはextends 1 | 2 | 3
のようなリテラル型による制約)がついている。 - 型推論中に
T
の具体的な型が確定しておらず、関数の返り値の型にT
がトップレベルで出現する。
トップレベルで出現するというのは、関数の返り値の型がT
自身である(T[]
とかではない)か、またはT
を含むunion型かintersection型であることを言います。
上記のid
関数の場合、2を満たしていますね。id(100)
という呼び出しではT = 100
という情報が得られましたが、他の引数で特にT
は使われていないため、戻り値の型を決めるときまでT
は確定していません。また、id
の返り値の型はT
なのでT
がトップレベルに出現しています。
試しに「型推論中にT
の具体的な型が確定しておらず」という条件を壊してみましょう。
function id<T>(value: T, callback: (arg: T) => void) {
return value;
}
// n の型は number 型
const n = id(100, num => {})
id
に特に使われない第2引数を追加しました。こうすると、T
は100
ではなくnumber
型に推論されるようになります。その理由はid
の第2引数num => {}
の型推論時にあります。この関数の型推論はcontextual typeがあり、それはid
の定義に従えば(arg: T) => void
です。よって、num
の型がT
となり、ここでT
の具体的な型が必要となるためT
が確定させられてしまいます。よって「T
の具体的な型が確定していない」の条件を満たさなくなったためT
に対してwideningが発生し、T
はnumber
型になるのです。
もうひとつの「関数の返り値の型にT
がトップレベルで出現する」のほうを壊すのは簡単です。次のように返り値の型をT[]
とかにするとT
の推論結果がnumber
に変わります。
function id<T>(value: T): T[] {
return [value];
}
// n の型は number[] 型
const n = id(100)
一方、T | null
とかはT
がトップレベルに出現している判定になりセーフです。
function id<T>(value: T): T | null {
return value
}
// n の型は 100 | null 型
const n = id(100)
最後にwideningが抑制されるもうひとつの条件「T
にextends string
のようなプリミティブ型による制約(またはextends 1 | 2 | 3
のようなリテラル型による制約)がついている」を試してみましょう。次の例はT
にT extends number
という制約を付けました。
function id<T extends number>(value: T): T[] {
return [value]
}
// n の型は 100[] 型
const n = id(100)
これは返り値の型がT[]
なので2つ目の条件は満たしていませんが、T extends number
があることでT
がwideningされずに100
型に推論されています。この挙動の理由は、T extends number
と書いた時点でT
としてリテラル型を期待しているのは明らかであるということからでしょう。どんな数値を渡しても全部T
がnumber
型にwideningされてしまうのでは、わざわざ型引数T
を作った意味がありません。
リテラル型が欲しくて型引数を作ったけどうまく推論してくれないという場面にこれから先出会うかもしれませんが、そのときはこの挙動を思い出しましょう。
まとめ
お疲れ様でした。長かった割に、TypeScriptを使いこなしている人にとっては当たり前の内容が多かったかもしれません。この記事で初めて知ったことが多かったという方は、きっとTypeScript力がついたことでしょう。
この記事全体を通して重要なことは、変数や関数の型は宣言された瞬間に決まるという原則です。これにより、TypeScriptの型推論は基本的にワンパスで、つまりプログラムを1度上から下、左から右に見ていくだけで行えるようになっています。これにより推論が失敗してしまう場合もありますが、これはTypeScriptの制限というよりはそういう言語デザインであると考えられます。この制限を克服するためには関数型言語などで採用されている類の本格的な型推論アルゴリズムを導入する必要がありますが、TypeScriptの複雑な型システムの上でそれをやるのは非常に難しいです。
関数の引数の型を(contextual typeにより推論できる場合を除いて)明示しないといけないというのも同じ原則から言えることです。宣言だけ見て関数の型が決まらないのはだめということですね。
ただ、例外もいくつか記事内で紹介しました。let
で宣言された関数やフロー解析による絞り込みがそれにあたります。
TypeScriptが型をうまく推論してくれない場合、この記事を理解してTypeScriptの型推論器の気持ちになればその理由が分かるかもしれません。機会があればぜひじっくりと考えてみてください。
最後になりますが、「このよく分からん挙動も解説してほしい」という要望があればぜひコメント欄に書いてください。応えられるかもしれません。
あわせて読みたい
TypeScriptの型シリーズ
-
部分型というのは、ざっくり言えばある型よりさらに条件が厳しい型のことです。
string
型は「文字列である」という条件を満たす値が許される型である一方、"abcde"
型は「"abcde"
である」という条件が必要です。"abcde"
は文字列ですから、「"abcde"
である」は「文字列である」の十分条件です。よって、"abcde"
型の条件を満たす値は全て(といっても"abcde"
だけですが)string
型の条件を満たすことになります。つまり、"abcde"
型の値はstring
型の値であると言うことができます。このとき、"abcde"
はstring
の部分型であると言います。部分型関係に関しては、TypeScriptは構造的部分型を採用しているという点が特徴的です。 ↩ -
undefined
を忘れていると思う読者の方がいるかもしれませんが、実はundefined
はリテラルではなく変数です。undefined
型自体はちゃんとあります。 ↩ -
一応JavaScriptにもクラスという概念はありますから、TypeScriptもクラスをサポートしています。実は
class Person {}
のような定義を書いた場合、TypeScriptではクラスと同時にPerson
型という型を定義したことになります。ただしこれは基本的には後述のような単なるオブジェクト型にPerson
という名前を勝手に付けてくれるだけであり、それゆえnew Person()
とする以外にもPerson
型のオブジェクトを作る方法はあります。 ↩ -
関数宣言はJavaScriptの仕様でhoistingされるので先に処理されたりしますが。 ↩
-
これはTypeScriptコンパイラの
src/compiler/checker.ts
内のgetCovariantInference
関数のコメントに書いてあります。今回解説していない内容が関わってくる部分は省いていますが。 ↩