TypeScriptの宣言空間とその不満

最近TypeScriptの型を触っていてハマったあたりのまとめ。だいたい仕様書に書いてあるとおりなので、すでに仕様書を読破している諸兄にはこの記事は必要ないです。

宣言空間 (declaration space) とは

宣言空間というのは、同一宣言空間で同じ名前が複数存在するとエラーになるような空間のこと。 TypeScriptには大きく分けて3つの宣言空間 (declaration space) がある *1

  1. 変数 (for variables) またはメンバー (for members)*2*3
  2. 型 (for named types)
  3. 名前空間 (for namespaces)

どういうことかというと、TypeScriptでは次のコードがエラーにならない。

var M = 0; // 変数宣言空間
interface M {} // 型宣言空間
module M {} // 名前空間宣言空間

変数とinterfaceとmoduleは別々の宣言空間に宣言されるためコンフリクトしない。

純粋なJavaScriptでは1.の変数宣言空間しか存在しない。上のTypeScriptをコンパイルすると、

var M;

になることからも分かる。

クラスの宣言空間

TypeScriptのクラスは変数宣言空間と型宣言空間の両方に宣言される。

var M = 0;
class M {} // Error: 変数宣言空間でコンフリクト
interface N {}
class N {} // Error: 型宣言空間でコンフリクト
module L {}
class L {} 
// OK: 名前空間宣言空間はコンフリクトしないのでエラーなし

2つのモジュール: instantiatedとnon-instantiated

ここまで「モジュールは名前空間宣言空間に宣言される」と書いてきたけど、これは non-instantiated モジュールに限った話。

non-instantiatedモジュールとは、簡単に言うとコンパイルしたら完全に消えてしまうモジュール。具体的には、interfaceまたは他のnon-instantiatedモジュールのみを含むモジュールのこと。

逆に言うと、変数、クラス、Enumなどを含むものは全てinstantiatedモジュールとなる。コンパイルしたらJSコードが生成されちゃうからね。

instantiatedモジュールは、名前空間宣言空間のほかに変数宣言空間にも宣言される。つまり、モジュールがinterface以外のメンバーを持つことで冒頭の例がエラーになる。

module M {
  var foo; // このメンバーによりMはinstantiatedモジュールになり変数宣言空間でも宣言される
}
var M = 0; // エラー:変数宣言空間でコンフリクト

これもコンパイル後のコードを見るとコンフリクトするのは分かりやすい。module Mが変数宣言空間(=JavaScriptの世界)で実体化 (instantiated) されるので、当然コンフリクトする。

var M;
(function (M) {
    var foo;
})(M || (M = {}));
var M = 0; // Conflict!!!

=で代入されるのは変数宣言空間のみ

さて、次のコードはb1の行は通るけどb2の行でエラーになる。

class A {}
var B = A;
var b1 = new B();
var b2: B; // Error: Could not find symbol 'B'.

B = AでBにAを代入しているはずなのに、Bが見つからないと言われてしまう。 これは、普通の=で代入されるのは変数宣言空間の変数のみであるため。 var b2: BのBは型指定なので、型宣言空間でBを探すけど、それはコピーされてないので見つからずエラーになる。

つまり、クラスを宣言した場合、変数宣言空間にはコンストラクタが宣言され、型宣言空間に型が宣言されるということ。だからコンストラクタ=で代入可能なのでnew B()は成功してる。

モジュールの代入

ではモジュールはどうか?

Non-instantiatedモジュールの場合、代入のところでエラーになる。

module M {}
var N = M; // Error: Could not find symbol 'M'.

理由はこれまで説明してきたとおり、non-instantiatedモジュールは名前空間型宣言だけに宣言されるため、変数宣言空間でMを探しても見つからないから。

Instantiatedモジュールの場合は変数宣言空間にも宣言されるので代入可能。

module M {
  class C {}
}
var N = M;
var c = new N.C();

ところが、この代入したNを使って型宣言はできない。

module M {
  class C {}
}
var N = M;
var c: N.C; // Error: Could not find symbol 'C'.

理由は(もうクドイけど)=による代入でコピーできるのは変数宣言空間だけなので、ここではCコンストラクタだけがコピーされたのであって型はコピーされてない。同様にモジュール内のInterfaceもコピーされない。

モジュールの完全なコピー(というかエイリアス)をやりたい人のために、TypeScriptでは専用のシンタックスが用意されている。varの代わりにimportを使うことで、モジュールとその内部の変数宣言空間、型宣言空間、名前空間型宣言をすべてコピーできる*4

module M {
  class C {}
}
import N = M; // importで代入
var c: N.C = new N.C(); // 型指定もコンストラクタもOK!

モジュールなしでクラス単体の型宣言空間をコピーすることはたぶんできないと思う。

追記: id:vvakame先生より、interfaceを使えば型宣言空間のコピー相当のことができるとご指摘いただきました!

型宣言空間と名前空間宣言空間の予約語

変数宣言空間にはECMAScriptの予約語があるけど、型宣言空間と名前空間宣言空間にはそれとは別に予約語がある。

予約語はPredefined Types*5と呼ばれる以下の5つ。

  • any
  • number
  • boolean
  • string
  • void

なので、こういうのはエラーになる。

module foo.number {} // Error
interface any {} // Error

逆にこういうのはエラーにならない。

var any = 1; // OK! 変数宣言空間では予約語ではない

ここで宣言されたのは変数宣言空間だけなので、型宣言空間のany型は無傷でそのまま使える。よくできてますね。

あとおもしろいところとして、ECMAScript予約語は関係ないのでこういうのもエラーにならない。

module foo.return {}

ただしTypeScriptとしてはエラーにならないだけで、(instantiatedモジュールの場合の)生成後のJavaScriptのコードは当然動かないです。

不満

実はここまで前置きでここから本題なんだけども。

型宣言空間でPredefined Typeが使えないのは分かるけど、名前空間宣言空間で使えないのが問題。少なくとも名前空間のルート以外は使えてよいと思う。というか使いたい。

具体的に困っていることとして、既存のgoog.stringみたいなモジュールの型宣言ファイルが書けない。

declare module goog.string { // Error: Module name cannot be 'string'.
}

importでは同じようにstringは使えないし、=による代入では前述したように通常の型宣言空間や名前空間宣言空間のコピーは不可能なので、現状回避策が見つかっていない。

例えばstringをstringsに置き換えてから変数stringをオブジェクト型で無理矢理作っても、

declare module goog.strings {
  export class Foo {}
}
declare module goog {
  export var string: {
    Foo: goog.strings.Foo
  }
}

前述の理由によりnew goog.string.Foo()はできるけどvar foo: goog.string.Fooはできない。 つまりこうなる。

var foo: goog.strings.Foo = new goog.string.Foo();

これはキモイ。 型指定はコンパイル後に消えるから動くは動くのだが、何かもよおしてしまいそうなほどキモイ。

うーむ。

*1:詳細はSpecification 2.3 Declarationsを参照

*2:このグループは仕様書ではまとめられていない(と思う)けど、自分の理解としては型と名前空間とは違うそれ以外のJavaScript空間に存在する宣言空間としてひとまとめにした。

*3:クラスのメンバーにはinstanceメンバーとstaticメンバーがある。

*4:TypeScriptのimportにはこの他に外部モジュールの読み込みという全く異なる2つの使い方があるので注意

*5:Specification 3.6.1