TypeScriptの`--esModuleInterop`は一体何をやっているのか

そんなオプションあったんですか

TypeScript2.7で追加されたらしい。 デフォルトでtrueとなるオプション。

Announcing TypeScript 2.7 | TypeScript

まずはどうなるかチェック

tsconfig.jsonesModuleInterop: falseをセットした時と、esModuleInterop: trueをセットした時で違いを見る。

オプションなし

//controller.ts
//falseの場合、CommonJSはrequireで読み込まなければならない
import commonjs = require('../CommonJS')
import esmodule from '../EsModule1'
import * as esmodule2 from '../EsModule2'

const commonobj = new commonjs();
commonobj.sayHello();
const esmoduleobj = new esmodule();
esmoduleobj.sayHello();
const esmodule2a_obj = new esmodule2.EsModuleA();
esmodule2a_obj.sayHello();
const esmodule2b_obj = new esmodule2.EsModuleB();
esmodule2b_obj.sayHello();

コンパイル結果

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const commonjs = require("../CommonJS");
const EsModule1_1 = require("../EsModule1");
const esmodule2 = require("../EsModule2");
const commonobj = new commonjs();
commonobj.sayHello();
const esmoduleobj = new EsModule1_1.default();
esmoduleobj.sayHello();
const esmodule2a_obj = new esmodule2.EsModuleA();
esmodule2a_obj.sayHello();
const esmodule2b_obj = new esmodule2.EsModuleB();
esmodule2b_obj.sayHello();
//# sourceMappingURL=controller_mix.js.map

オプションあり

//trueの場合import from句で読み込める
import commonjs from '../CommonJS'
import esmodule from '../EsModule1'
import * as esmodule2 from '../EsModule2'

const commonobj = new commonjs();
commonobj.sayHello();
const esmoduleobj = new esmodule();
esmoduleobj.sayHello();
const esmodule2a_obj = new esmodule2.EsModuleA();
esmodule2a_obj.sayHello();
const esmodule2b_obj = new esmodule2.EsModuleB();
esmodule2b_obj.sayHello();

コンパイル結果

//controller.js
"use strict";
//__importDefault, __importStarというヘルパメソッドが追加されている
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 CommonJS_1 = __importDefault(require("../CommonJS"));
const EsModule1_1 = __importDefault(require("../EsModule1"));
const esmodule2 = __importStar(require("../EsModule2"));
const commonobj = new CommonJS_1.default();
commonobj.sayHello();
const esmoduleobj = new EsModule1_1.default();
esmoduleobj.sayHello();
const esmodule2a_obj = new esmodule2.EsModuleA();
esmodule2a_obj.sayHello();
const esmodule2b_obj = new esmodule2.EsModuleB();
esmodule2b_obj.sayHello();
//# sourceMappingURL=controller_mix.js.map

では、オプションありのコンパイル結果を詳しく見ていくことにする。

冒頭でヘルパメソッドがいくつか宣言されていることがわかる。 __importDefaultimport module from 'abc'のために、__importStarimport *のために用意されるヘルパメソッド。

//default import
import commonjs from '../CommonJS'
//default import
import esmodule from '../EsModule1'
//namespace-style import
import * as esmodule2 from '../EsModule2'

上記のコードは、それぞれ以下のようにコンパイルされる。

const CommonJS_1 = __importDefault(require("../CommonJS"));
const EsModule1_1 = __importDefault(require("../EsModule1"));
const esmodule2 = __importStar(require("../EsModule2"));

__importstarの引数はrequire('../EsModule2')となっている。これはrequireで引っ張ってきたモジュールオブジェクトを指している。 よってfunction(mod)function(require(...))に等しい。

さて、ここでEsModule2.tsを読むと、

//EsModule2.ts
export class EsModuleA{
    sayHello(){
        console.log('hello from esmoduleA')
    }
}
export class EsModuleB{
    sayHello(){
        console.log('hello from esmoduleA')
    }
}

となっており、コンパイル結果は

//EsModule2.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
class EsModuleA {
    sayHello() {
        console.log('hello from esmoduleA');
    }
}
exports.EsModuleA = EsModuleA;
class EsModuleB {
    sayHello() {
        console.log('hello from esmoduleA');
    }
}
exports.EsModuleB = EsModuleB;
//# sourceMappingURL=EsModule2.js.map

となっている。

冒頭でexports__EsModuleプロパティにtrueがセットされていることがわかる。 これは、import対象のモジュールがEsModuleの形式に準拠して書かれている(いた)ことを示す。 requireで取得できるのは対象ファイルのexportsが指しているオブジェクトなので、require('../defaultExport_namespace').__esModuletrue値が取れる。

ちなみに

話が脱線するけど、各モジュールの初期化時にはexports = module.exports = {}が暗黙的に実行されており、exportsはただのエイリアスmodule.exportsの参照が入っているだけ。 なので、exports = { foo: 'bar'}とかやるとexportsは別のオブジェクトを参照するようになってしまい、requireしてもfooプロパティを取得することはできなくなってしまう。 exports.foo = 'bar'module.exports.foo = 'bar'とやってあげる必要がある。 ...とここまで書いて気がついたけど、Node.jsでよくモジュールの末尾にmodule.exports = someObjとかやってたのはES6でいうexport default someObjに等しかったんだなあ。

もしNode.jsで複数のexportをしたかったら、

module.exports = {
  foo: 'foo',
  bar: 'bar'
  baz: {
    baz_foo: ...
  }
}

//もしくは
module.exports.foo = 'foo';
module.exports.bar = 'bar';
module.exports.baz = { baz_foo: ... };

とかで実現可能。

要は、requireで返ってくるのはmodule.exportsであり、exportsが最終的に指している参照ではない。

__esModuleがつく/つかないの基準

基準は簡単で、対象のモジュールがCommonJS形式で書かれているかどうか。

CommonJS形式でモジュールのexportを書くと、コンパイル後に__esModuleプロパティが生成されない。

例えば、このモジュールはCommonJS方式で記述されている。 (export = hogeというのがCommonJS方式)

//CommonJS.ts
class common {
    sayHello (){
        console.log('hello from commonJS')
    }
}
export = common

これは以下のようにコンパイルされる。

//CommonJS.js
"use strict";
class common {
    sayHello() {
        console.log('hello from commonJS');
    }
}
module.exports = common;
//# sourceMappingURL=CommonJS.js.map

コンパイル後も、esModuleプロパティは特に追加されていない。

ちなみに、コンパイル後のexportがmodule.exports = common;と、Node.js(CommonJS)形式で書かれているのは、

  "compilerOptions": {
    /* Basic Options */
    "target": "es2017",
    "module": "commonjs",
    }

とオプションをセットしているからであって、

  "compilerOptions": {
    /* Basic Options */
    "target": "es2017",
    "module": "ES2015",
    }

とか書くとexport周りのコンパイル結果もEsModule準拠になる。 (この場合、CommonJSでexportが記述されているファイルはコンパイルすらできなくなる)

esModuleInteropの働き

さて、ここまで書いて「じゃあesModuleInteropってなんのためにあんの?」という話になる。 具体的には、以下のような結果をもたらす。

//この書き方はesModuleInterop: falseでエラーとなる
//import common from '../CommonJS'

//この書き方はtrue/falseに関わらずエラーにはならない
import common = require('../CommonJS')

//この書き方はtrue/falseに関わらずエラーになる
//import * as common from '../CommonJS'

esModuleInteropは、今までimport = requireと書くしかなかったCommonJSのモジュールをimport from形式でimportできるように前処理を入れるためのオプションとなる。 (記事トップの例ではすでにesModuleInterop: trueとなっていたためimportで読み込んでいる)

ここでTypeScriptのDocumentを確認して見ると、

Option: --esModuleInterop Type: boolean Default: false Description: Emit importStar and importDefault helpers for runtime babel ecosystem compatibility and enable --allowSyntheticDefaultImports for typesystem compatibility.

とのことで、__importStar__importDefaultヘルパをbabelのために有効にする、といった記述がある。

//esModuleInterop:true かつ import from
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const CommonJS_1 = __importDefault(require("../CommonJS"));
const ob = new CommonJS_1.default();
//# sourceMappingURL=controller_commonJS.js.map


//import = require
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const common = require("../CommonJS");
const ob = new common();
//# sourceMappingURL=controller_commonJS.js.map

babel関係あるの?

Announcing TypeScript 2.7 | TypeScript

ES2015が策定される以前、JavaScriptは有象無象のモジュール相互運用(srcipt src, require.js, nodejs, etc...)があり、 そういった"Legacy"なモジュールたちをうまく扱うための方法が必要があった。 で、TypeScriptとBabelはそれぞれ別の方法で実装してしまったため、その辺の埋め合わせが必要になったと。 それがesModuleInteropオプション。

BabelとWebpackはdefault importsとしてCommonJSのモジュールインポートを許可しており、 さらに__esModuleプロパティがないモジュールに限ってnamespace importsも許可している。

//Babel, Webpack
import _, { pick } from 'lodash';

_.pick(...);
pick(...);

これはTypeScriptの挙動とは異なるため、allowSyntheticDefaultImportsオプションがv1.8で追加され、TypeScript上での型チェックができるようにした。 これがtrueになるとdefault exportなしにdefault importが許可されるらしい。特にコンパイル結果そのものには影響しない。

一般的に、TypeScriptでのCommonJS(とAMD)モジュールのimportは以下のようになっている。 namespace importは常にCommonJSモジュールオブジェクトの形式と一致し、 default importはdefaultというモジュールのメンバ名と一致する。

この仮定により、named importを生成することができる。

//TypeScript

//これはnamed import
import { range } from 'lodash';

//default importの場合はこう
//import someFunc from 'abc';
//対象のモジュールがexort defaultされている場合は、
//named importでdefaultというメンバ名を指定してやってもいいらしい
//import { default } from 'abc';

for(let i of range(10)){...}

しかし、ES形式のnamespace importはTypeScript上でCallableとならず、うまく動かない。 (Callableというのはabc()のような、importした対象をそのまま実行することっぽい)

//TypeScript
import * as express from 'express';

//not working
let app = express();

BabelやWebpackの挙動と同じようにするため、legacyなモジュール形式を許可するためのesModuleInteropオプションが追加された。

esModuleInteropオプションが有効になっていると、CallableなCommonJSモジュールは必ずdefault Importとしてimportされる必要がある。

```typescript //TypeScript import express from 'express';

let app = express(); ```

締めくくりとして、Callable/Constructableなモジュールをexportしているライブラリ(Expressとか)を使うNodeユーザには、このオプションを有効にするように強く勧めている。

つまり、これまでBabelやWebpackではCommonJSをdefault Importで読み込めていたのに、TypeScriptではできなかったのでその辺はどうにかしたいよね、というお気持ちがあった。 で、今回esModuleInteropオプションを有効にすることで、TypeScriptでもCommonJSをdefault import形式で読み込めるようなヘルパメソッドを生成するようにしたよ、ということ。 その代わり、今までconst module = require('module')とか書いてたコードではなく、import module from 'module'という形式で書いたほうがいいですよ(強制ではないが)、ということらしい。

有効時は以下のような挙動になる。

//CommonJS.ts
class common {
    sayHello (){
        console.log('hello from commonJS')
    }
}
export = common


//controller.ts
//この書き方はesModuleInterop: falseでエラーとなる
import common from '../CommonJS'

//この書き方はtrue/falseに関わらずエラーにはならない
//エラーにはならないが、上記の書き方にしたほうがいい
//import common = require('../CommonJS')

//この書き方はtrue/falseに関わらずエラーになる
//import * as common from '../CommonJS'

const obj = new common();

これをコンパイルするとこうなる。

//CommonJS.js
"use strict";
class common {
    sayHello() {
        console.log('hello from commonJS');
    }
}
module.exports = common;
//# sourceMappingURL=CommonJS.js.map


//controller.ts
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const CommonJS_1 = __importDefault(require("../CommonJS"));
const obj = new CommonJS_1.default();

ところで、allowSyntheticDefaultImportsと何が違うのか考えてみたけど、allowSyntheticDefaultImportsコンパイル結果を変えずにdefault importを許可するように挙動を変えていたのに対し、 esModuleInteropコンパイル結果にヘルパメソッドを差し込むことで似たような挙動に近づけよう、というアプローチを取っている部分かなあ、と思う。

webpack上で動かしていないからわからないが、allowSyntheticDefaultImportsはうまく動かないケースもあるらしい。

abcdef.gets.b6n.ch

結局のところ

結果として、今までimport module = require('abc');と書くしかなかったのが、import module from 'abc';がCommonJSなモジュールにも利用できるようになったよ、とのこと。 とはいっても今までそういうモジュールにぶち当たったことがないのですごいオプションかどうかはわからない。 @types/nodeとか見てもビルトインモジュールはだいたいdeclare module 'hoge'みたいな感じでちゃんとexportされてるし、自分でCommonJSスタイルなモジュールを書く機会は無いような気がする。

素直に1 file =>1 exportにするのが一番わかりやすいはず。

class hoge {}
export default hoge

npm packageを作るときはindex.jsに複数のexportを持たせる、とかはあるかも?

おわりに

しかしまあそこそこふつうのJSを生成してくれるのでありがたい。