TypeScriptの`--esModuleInterop`は一体何をやっているのか
そんなオプションあったんですか
TypeScript2.7で追加されたらしい。
デフォルトでtrue
となるオプション。
Announcing TypeScript 2.7 | TypeScript
まずはどうなるかチェック
tsconfig.json
にesModuleInterop: 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
では、オプションありのコンパイル結果を詳しく見ていくことにする。
冒頭でヘルパメソッドがいくつか宣言されていることがわかる。
__importDefault
はimport module from 'abc'
のために、__importStar
はimport *
のために用意されるヘルパメソッド。
//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').__esModule
でtrue
値が取れる。
ちなみに
話が脱線するけど、各モジュールの初期化時には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
はうまく動かないケースもあるらしい。
結局のところ
結果として、今まで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を生成してくれるのでありがたい。