コンパイル結果で考えるTypeScriptのimport / export / namespace

import * as module from 'module' vs import module from 'module'

TypeScriptでモジュールのimportをしていたら、読み込むモジュールによって怒られたり怒られなかったりしたので色々試して調べた。

大前提

TypeScriptにはexternal moduleとinternal moduleという2つのモジュールの概念がある。

external module

トップレベルにimport exportを持つファイルのこと。他のファイルからimportされることを前提にしている。 モジュールと名前がついているが、単位はファイル。classや他の言語のPackageみたいに特別な宣言をするとかではなく、とにかくimport exportがあったらexternal module。 なんだかややこしい。

internal module

名前空間を宣言するもの。変数名などの衝突を防ぐために使う。 宣言にはnamespaceを使う。 基本的によそのモジュールから使われるために記述するわけではないが、namespaceそのものをexportすれば外部から利用することはできる。(それが意味のあるコードかは別として)

そもそもなんでモジュールの概念がある?

もともとJavaScriptは別ファイルを参照し、その中の公開された関数を使うという概念がなく、ブラウザ上でHTMLに記述された<script src=... />タグに記述されたJavaScriptファイルを上から順にどんどんロードしていってグローバル変数やグローバル関数をガンガン宣言しまくるといった所業がなされていた。 なので、HTML上で各ファイルのJavaScriptを読み込む順番によっては正しく動かない、というのがよくあった。 こういう理由でグローバル汚染が蔓延していて、よく名前衝突が起こって色々と終わり込んでいたらしい。 そこでJavaScriptでも何らかのファイル間参照機能が必要だよね、というお気持ちからできたのがモジュールの概念。

https://qiita.com/mizchi/items/6b569cc75dbcc26a1f15

で、そのモジュールの参照方法が昔のTypeScript(~1.4)とNode.js(CommonJS)とES6で異なるのが原因。うーん最高。

CommonJSとは

ブラウザ外のJavaScriptの各種仕様を定めることを目的としたプロジェクト、およびその仕様のこと。 昔はServerJSとかいう名前だったらしい。

https://ja.wikipedia.org/wiki/CommonJS

http://wiki.commonjs.org/wiki/CommonJS

で、このCommonJSの仕様に従ってる(ように見える)のがNode.js。

TypeScriptの場合

TypeScriptはES6の仕様策定前にこの辺の仕様が決まったため、moduleというキーワードでモジュールを定義する手法を取っていた。(現在ではmoduleキーワードはnamespaceキーワードに変更されている)

https://www.typescriptlang.org/docs/handbook/modules.html

//modA.ts
module modA {
  export class classA {}
}

//modB.ts
/// <reference path="./modA.ts" />

module modB {
  import clsA = modA.classA;
}

さて、冒頭でnamespaceが内部モジュールを表すという話があったが、(おそらく)この時点では外部モジュールという概念はなく、単に「モジュール」という概念しかどうやらなかったらしい。

一方ES6

moduleというキーワードを用いずにモジュールを表す方式を取っている。

//classA.js
export class classA {}
export function hoge() {}

//classB.js
import { classA, hoge } from "./classA"
const objA = new classA();
hoge();

//あるいは一括で読み込んでClsAという名前空間に放り込む
import * as ClsA from "./ClassA.js"
const objA_2 = new ClsA.classA();
ClsA.hoge();

ちなみにNode.js

requireexportsというキーワードでモジュールを取り扱っている。最高。

//classA.js
class classA {}
module.exports = classA

//classB.js
const classA = require('./classA');

const objA = new classA();

なので

こういった背景があり、TypeScriptでモジュールと一口に言っても昔のTypeScriptのモジュールなのかES6以降のモジュールなのかNode.jsのモジュールなのかという話がついて回るようになった。

ちなみにTypeScriptには、モジュールをどのように取り扱うか(TSファイルのコンパイル時、コンパイル後のJSファイル)のモード指定ができる。

TypeScriptのコンパイルを行うtscコマンドを使い、tsc --initコンパイルオプションを指定するtsconfig.jsonというファイルを生成できるが、 デフォルトではCommonJSに互換性のあるモードが指定されている。

namespaceとeternal moduleはどっちを使うべきか?

namespaceはJSにコンパイルすると分かるが、下位の変数や関数を無名関数にラップしてクロージャを生成することでグローバル汚染を防ぐ、といったようなJSが生成される。

例えばこんな感じ。

//classA.ts
namespace NameSpaceA {
    export class classA {
        sayHello() {
            const objB = new classB();
            objB.sayHello();
        }
    }
    class classB {
        sayHello() {
            console.log('hello');
        }
    }
}

コンパイルするとこう。

//classA.js
"use strict";
var NameSpaceA;
(function (NameSpaceA) {
    class classA {
        sayHello() {
            const objB = new classB();
            objB.sayHello();
        }
    }
    NameSpaceA.classA = classA;
    class classB {
        sayHello() {
            console.log('hello');
        }
    }
})(NameSpaceA || (NameSpaceA = {}));

TypeScript/ES6では、あくまでexport句が書かれたモジュールをimport句で指定することで初めて参照が可能になるため、このままでは外部から利用することができない。 C#Javaと違っていわゆる「パッケージ」とか「プロジェクト」という単位で参照して名前空間を共有、というのができないので、各モジュールで同じ名前空間を定義しても大した恩恵は得られない気がする。

例えば、export namespace hogeとすることでexportすることはできるが、C#Javaと違い同名ではあるものの独自の名前空間を作り出す。 これはどういうことかというと、以下のようなコードを書いたとして、ModA.tsModB.tsでファイル毎に独立したShareNS名前空間を生成するので、相互に参照といったことができない。 逆に、同一ファイル内であれば、ModC.tsのように別の場所で宣言された名前空間同士で相互参照ができる場合がある。

//ModA.ts
export namespace ShareNS {
  export class A {}
}

//ModB.ts
export namespace ShareNS {
  export class B {}
}

//ModC.ts

//TSの仕様として、同一のroot containerを持つnamespaceはマージされる
export namespace ShareNS {
  class A{
  }
}

export namespace ShareNS {
  class B{
  }
}

//上記二つのNSは以下と同値
/*
export namespace ShareNS{
  class A{
  }
  class B{
  }
}
*/

//exportをつける/つけないでroot containerが変わるので、以下のnamespaceはマージされない
namespace ShareNS {
}

TSにしろES6にしろ、class記法がサポートされているので、個人的にはほぼ全ての自作モジュールは以下の記述方法に統一している。 サーバもクライアントも共通のクラスを使いたいみたいな場合はまた話が変わるかも。

//Hoge.ts
class Hoge {
    sayHello() {
        console.log('hello from Hoge')
    }
}

export default Hoge

//hogeController.ts
import Hoge from './hoge'

const hoge = new Hoge();
hoge.sayHello();

コンパイルするとこうなる

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
class Hoge {
    sayHello() {
        console.log('hello from Hoge');
    }
}
exports.default = Hoge;

//hogeController.js
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const hoge_1 = __importDefault(require("./hoge"));
const hoge = new hoge_1.default();
hoge.sayHello();

ほとんど変わりない。

コンパイル結果から見るモジュールの挙動

ここからが本題。

前提条件

tsconfig.jsonは以下の通り。とりあえず"target": "es2017", "module": "commojs"以外はあまり関係ない。

{
  "compilerOptions": {
    /* Basic Options */
    "target": "es2017",
    "module": "commonjs",
    "lib": [
      "es2017",
      "dom"
    ],                             /* Specify library files to be included in the compilation. */
    "declaration": false,                   /* Generates corresponding '.d.ts' file. */
    "outDir": "./dist",                        /* Redirect output structure to the directory. */
    
    /* Strict Type-Checking Options */
    "strict": true,                           /* Enable all strict type-checking options. */
    "noImplicitAny": false,                 /* Raise error on expressions and declarations with an implied 'any' type. */
    "strictNullChecks": true,              /* Enable strict null checks. */
    
    /* Module Resolution Options */
    "moduleResolution": "node",            /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
    "typeRoots": [
      "node_modules/@types",
      "./src/typings"
    ],                       /* List of folders to include type definitions from. */
    
    // "allowSyntheticDefaultImports": true,  /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
    "esModuleInterop": true,                   /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */

    /* Experimental Options */
    "experimentalDecorators": true,        /* Enables experimental support for ES7 decorators. */
    "emitDecoratorMetadata": true         /* Enables experimental support for emitting type metadata for decorators. */
  }
}

namespaceをexportしなかった場合

上記でも少し触れたが、namespaceをexportしなかった場合。

//NameSpaceA.ts
namespace NameSpaceA {
    export class NameSpaceA_ClassA {
        constructor() {
        }
    }
}

//controller.ts
//NameSpaceA名前空間にClassAを宣言しておくと、importしなくても使える...
//と思いきや、コンパイルは通るが、実行時エラーとなる。
//NameSpaceA.tsはトップレベルにexport/importがないので、外部モジュールではない

const classA_obj = new NameSpaceA.NameSpaceA_ClassA();

コメントの通り、これはコンパイルは通るがJSの実行時エラーが発生する。 exportしないnamespaceは同一ファイル間での名前衝突避けぐらいにしか使うことができない。

コンパイル結果は以下の通り。

//NameSpaceA.js
"use strict";
//クロージャを作ってNameSpaceAというオブジェクトにスコープを限定している
var NameSpaceA;
(function (NameSpaceA) {
    class NameSpaceA_ClassA {
        constructor() {
        }
    }
    NameSpaceA.NameSpaceA_ClassA = NameSpaceA_ClassA;
})(NameSpaceA || (NameSpaceA = {}));
//# sourceMappingURL=NameSpaceA.js.map

//controller.js
"use strict";
//何もrequireしていないので当然参照できない
const classA_obj = new NameSpaceA.NameSpaceA_ClassA();
//# sourceMappingURL=controller_namespaceA.js.map

実行時エラーが起きるのはまあ当然なんだけど正直これはコンパイルエラーにしてほしい。 (オプションによってはなるかも?)

namespaceをexportした場合

今度はexportしてみる。

//NameSpaceB.ts
export namespace NameSpaceB{
    class InternalClass {
        constructor(){}
        sayHello(){
            console.log("Say hello from InternalClass");
        }
    }
    export class ExternalClass{
        sayHello(){
            const internal = new InternalClass();
            internal.sayHello();
        }
        sayInternalNamespace() {
            const internal_intenal = new MyInternalSpace.MyInternalClass();
            internal_intenal.sayInternalHello();
        }
    }

    //MyInternalSpaceはexportされていないので、このモジュールより外からは参照できない
    namespace MyInternalSpace {
        //このクラスはNameSpaceBからは参照可能
        export class MyInternalClass {
            sayInternalHello() {
                const secretObj = new MyInternal_InternalClass();
                secretObj.secretSayHello();
            }
        }

        //このクラスはNameSpaceBからでも参照不可能
        class MyInternal_InternalClass {
            secretSayHello(){
                console.log("say hello from internal namespace!")
            }
        }

    }
}

//controller.ts
//NameSpaceBはdefault exportを持っていないので、importの対象を指定するか、
//import * as NameSpaceB from ... と書く必要がある。
//ただし、namespace-style importではnsbという名前空間の中にNameSpaceBがネストされるので冗長。

import { NameSpaceB } from './NameSpaceB'
import * as nsb from './NameSpaceB'

const classB_obj = new NameSpaceB.ExternalClass();
classB_obj.sayHello();
classB_obj.sayInternalNamespace();

const classB_obj_2 = new nsb.NameSpaceB.ExternalClass();
classB_obj_2.sayHello();
classB_obj_2.sayInternalNamespace();

コンパイル結果は以下の通り。

//NameSpaceB.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
//namespaceはNameSpaceBオブジェクトに変換され、内部のclassなどはそのオブジェクト内に押し込まれる
//クロージャを生成してスコープを限定している
var NameSpaceB;
(function (NameSpaceB) {
    class InternalClass {
        constructor() { }
        sayHello() {
            console.log("Say hello from InternalClass");
        }
    }
    class ExternalClass {
        sayHello() {
            const internal = new InternalClass();
            internal.sayHello();
        }
        sayInternalNamespace() {
            const internal_intenal = new MyInternalSpace.MyInternalClass();
            internal_intenal.sayInternalHello();
        }
    }
    NameSpaceB.ExternalClass = ExternalClass;
    
    //exportされなかったnamespaceはlet演算子でスコープが限定され、この無名関数より外側から参照できなくなる
    let MyInternalSpace;
    (function (MyInternalSpace) {
        //exportされたclassは、上位のMyInternalSpaceを参照できるトップレベルの無名関数からは参照可能
        class MyInternalClass {
            sayInternalHello() {
                const secretObj = new MyInternal_InternalClass();
                secretObj.secretSayHello();
            }
        }
        MyInternalSpace.MyInternalClass = MyInternalClass;
        
        //exportされなかったclassは、この無名関数より外側から参照不可能
        class MyInternal_InternalClass {
            secretSayHello() {
                console.log("say hello from internal namespace!");
            }
        }
    })(MyInternalSpace || (MyInternalSpace = {}));
})(NameSpaceB = exports.NameSpaceB || (exports.NameSpaceB = {}));
//# sourceMappingURL=NameSpaceB.js.map

//controller.js
"use strict";
var __importStar = (this && this.__importStar) || function (mod) {
    if (mod && mod.__esModule) return mod;
    var result = {};
    if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k];
    result["default"] = mod;
    return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
//"target": "commonjs"オプションが指定されているので、importは全てconst requireに変換されている。
//module importとnamespace-style importではコンパイル結果が違うが、
//いずれにせよ、モジュールをいったんオブジェクトに格納して、オブジェクトのプロパティとして取得している
const NameSpaceB_1 = require("./NameSpaceB");
const nsb = __importStar(require("./NameSpaceB"));
const classB_obj = new NameSpaceB_1.NameSpaceB.ExternalClass();
classB_obj.sayHello();
classB_obj.sayInternalNamespace();
const classB_obj_2 = new nsb.NameSpaceB.ExternalClass();
classB_obj_2.sayHello();
classB_obj_2.sayInternalNamespace();
//# sourceMappingURL=controller_namespaceB.js.map

実行結果

Say hello from InternalClass
say hello from internal namespace!
Say hello from InternalClass
say hello from internal namespace!

関数やオブジェクトをexportした場合

今度は、napespaceではなく関数やオブジェクトをexportしてみる。

//ModuleA.ts
//トップレベルにオブジェクトや関数のexportを直接記述
export var foo: string = 'abc'
export function baz() {
    return false;
}

//controller.ts
//default exportがない外部モジュールをimportするには二通りの方法がある
//1. { } で、対象の外部モジュール内に宣言されている変数名や関数名を指定する
import { foo, baz } from '../ModuleA'

//2. import * as ABC from... という形で、その外部モジュールをABCという名前空間にロードする
//この記述方法をnamespace-style importと呼ぶ
import * as bar from '../ModuleA'

bar.baz();

//fooは外部モジュールのメンバを直接importしているのでそのまま使える
console.log(foo);

//barはあくまで名前空間なので、メンバ名を指定してやる必要がある
console.log(bar.foo);
console.log(bar);

コンパイル結果。 tsconfig.jsonの設定で"module": "commonjs"を指定したので、Node.jsスタイルのexportに変換されていることがわかる。

//ModuleA.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
//exportsのプロパティにセットされている
exports.foo = 'abc';
function baz() {
    return false;
}
exports.baz = baz;
//# sourceMappingURL=ModuleA.js.map

//controller.js
"use strict";
var __importStar = (this && this.__importStar) || function (mod) {
    if (mod && mod.__esModule) return mod;
    var result = {};
    if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k];
    result["default"] = mod;
    return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
const ModuleA_1 = require("../ModuleA");
const bar = __importStar(require("../ModuleA"));
bar.baz();
console.log(ModuleA_1.foo);
console.log(bar.foo);
console.log(bar);
//# sourceMappingURL=controller_moduleA.js.map

実行結果

abc
abc
# barにはfooプロパティとbazプロパティが格納されていることがわかる
{ foo: 'abc', baz: [Function: baz] }

ちなみに"module": "ES2015"を指定した場合はこんな感じのコンパイル結果になる。

//ModuleA.js
export var foo = 'abc';
export function baz() {
    return false;
}

//controller.js
import { foo } from './ModuleA';
import * as bar from './ModuleA';
bar.baz();
console.log(foo);
console.log(bar.foo);
console.log(bar);

スッキリしていて良いが、Node.jsで読み込めない。悲しい。

classをexportした場合

お次はclass。

//class1.ts

//Top-Levelにexportがあるのでこれは外部モジュール
export class class1 {
    constructor(){
    }

    /*
    /クラス内でnamespaceは定義できない
    namespace internalNameSpace1{
    }
    */
}

//controller.ts
import { class1 } from './class1'

const obj1 = new class1();

コンパイル結果

//class1.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
class class1 {
    constructor() {
    }
}
exports.class1 = class1;
//# sourceMappingURL=Class1.js.map

//controller.js
const Class1_1 = require("./class1");
const obj1 = new Class1_1.class1();

default export

今度はdefault export句を使った場合。

default exportはその外部モジュールが1つしか関数、オブジェクト、クラス、名前空間などをexportしない場合に、利用側に「このモジュールはたった1つしかexportしませんよ」と伝えるためのキーワード。

この書き方で外部モジュールを書いた場合、普通だったら

//foo.ts
export function foo () {}

//bar.ts
export function bar () {}


//controller.ts
import { foo } from './foo';
import * as bar from './bar';

と書かなければならないところを、

//foo.ts
function foo () {}
export default foo;

//bar.ts
function bar () {}
export default bar;

//controller.ts
import foo from './foo';
import bar from './bar';

foo();
bar();

と書くことができる。

こちらはやや長めのサンプルコード。

//defaultExport.ts
function defaultExport() {
    console.log('hello this is default export')
}
export default defaultExport;


//defaultExport_class.ts
class defaultExport{
    constructor() {
    }
    sayDefault(){
        console.log("hello this is default export class")
    }
}
export default defaultExport


//defaultExport_namespace.ts
namespace DefaultNameSpace {
    //default exportでDefaultNameSpaceが指定されているので、他のモジュールからこのクラスは参照可能
    export class ExternalClass {
        sayHello() {
            const external_internal = new InternalClass();
            external_internal.sayInternal();
        }
    }
    //このクラスはDefaultNameSpaceの内部でしか参照できない。
    class InternalClass {
        sayInternal() {
            const internal_external = new InternalNameSpace.InternalExternalClass();
            internal_external.sayInternalExternal();
        }
    }

    //exportされたnamespaceの内部にnamespaceをネストすると、こちらはDefaultNameSpaceより外から参照不可能
    namespace InternalNameSpace {
        export class InternalExternalClass {
            sayInternalExternal() {
                const internal_internal = new InternalInternalClass();
                internal_internal.sayInternalInternal();
            }
        }
        //こちらはInternalNameSpace内からしかアクセスできない
        class InternalInternalClass {
            sayInternalInternal() {
                console.log('hello from internal-internal class')
            }
        }
    }
}
export default DefaultNameSpace


//contoller.ts
import default_function from '../defaultExport'
import default_class from '../defaultExport_class'

//ちなみにdefault-style import/namespace-style importのどっちで読み込んでもいい
import default_NameSpace from '../defaultExport_namespace'
import * as default_NameSpace2 from '../defaultExport_namespace'

default_function()

const default_obj = new default_class();
default_obj.sayDefault();

//ExternalClassにしかアクセスできない
const default_namespace = new default_NameSpace.ExternalClass();
default_namespace.sayHello();

const def_ns = new default_NameSpace2.default.ExternalClass();
def_ns.sayHello();

コンパイル結果

//defaultExports.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
function defaultExport() {
    console.log('hello this is default export');
}
exports.default = defaultExport;
//# sourceMappingURL=defaultExport.js.map


//defaultExports_class.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
class defaultExport {
    constructor() {
    }
    sayDefault() {
        console.log("hello this is default export class");
    }
}
exports.default = defaultExport;
//# sourceMappingURL=defaultExport_class.js.map

//defaultExports_namespace.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
var DefaultNameSpace;
(function (DefaultNameSpace) {
    class ExternalClass {
        sayHello() {
            const external_internal = new InternalClass();
            external_internal.sayInternal();
        }
    }
    DefaultNameSpace.ExternalClass = ExternalClass;
    class InternalClass {
        sayInternal() {
            const internal_external = new InternalNameSpace.InternalExternalClass();
            internal_external.sayInternalExternal();
        }
    }
    let InternalNameSpace;
    (function (InternalNameSpace) {
        class InternalExternalClass {
            sayInternalExternal() {
                const internal_internal = new InternalInternalClass();
                internal_internal.sayInternalInternal();
            }
        }
        InternalNameSpace.InternalExternalClass = InternalExternalClass;
        class InternalInternalClass {
            sayInternalInternal() {
                console.log('hello from internal-internal class');
            }
        }
    })(InternalNameSpace || (InternalNameSpace = {}));
})(DefaultNameSpace || (DefaultNameSpace = {}));
exports.default = DefaultNameSpace;
//# sourceMappingURL=defaultExport_namespace.js.map


//controller.js
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
var __importStar = (this && this.__importStar) || function (mod) {
    if (mod && mod.__esModule) return mod;
    var result = {};
    if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k];
    result["default"] = mod;
    return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
const defaultExport_1 = __importDefault(require("../defaultExport"));
const defaultExport_class_1 = __importDefault(require("../defaultExport_class"));
const defaultExport_namespace_1 = __importDefault(require("../defaultExport_namespace"));
const default_NameSpace2 = __importStar(require("../defaultExport_namespace"));
defaultExport_1.default();
const default_obj = new defaultExport_class_1.default();
default_obj.sayDefault();
const default_namespace = new defaultExport_namespace_1.default.ExternalClass();
default_namespace.sayHello();
const def_ns = new default_NameSpace2.default.ExternalClass();
def_ns.sayHello(); 
//# sourceMappingURL=controller_defaultExport.js.map

実行結果

hello this is default export
hello this is default export class
hello from internal-internal class
hello from internal-internal class

まとめ

なんだかたくさん書いた気がするけど、やっぱりexport default class{}が一番わかりやすように感じる。class単位にしておけば内部でpublic/privateの設定もできるし、しかも責務の範囲が明確になりやすい。

逆にutil.tsとか作ってexport function util1(){}とかやり始めるとutilモジュールが無限に肥大化していってよくないな、となんだか良くない方向になりそう。

ちなみに今回は触れなかったが、esModuleIteropというオプションがあり、これまたJavaScriptが大好きになってしまう(うわー、JavaScript、だーいすき)ので次回紹介していきたい。(感情、無し)

おわりに

た、TypeScript最高・・・