TypeScriptで複数ファイル構成する2つの方法

TypeScriptで複数ファイル構成のプロジェクトを扱う方法について書いてみる。日本語の入門記事や試してみました系の記事で勘違いされてることがたまに見受けられるので、整理してみる。

公式のModules in TypeScriptを既に読んでおられるような御仁は回れ右していただいても結構です。

やりたいこと

  • ソースファイルをモジュールごとに分割して管理したい
  • 実行環境はNode.js or ブラウザ

例えば、こういう処理があって、

// main.ts
function trimLeft(str: string): string {
    return str.replace(/^\s+/, '');
}

var input = document.getElementsByTagName('input')[0];
input.value = trimLeft(input.value);

trimLeft()を別のページでも使いたいから別ファイルに切り出してユーティリティ化したい、と思ったときどうするかという話。

その1: <reference>によるグローバルモジュール

普通のJavaScriptだったら、一番単純なのはutil/strings.jsとか別ファイルに切り出して、ブラウザのscriptタグを追加すれば良い。

<script src="util/strings.js">
<script src="main.js">

TypeScriptではコンパイル時に型情報が必要なので、main.tsからtrimLeft()の定義を削除しただけだとコンパイルエラーになってしまう。

そこで///から始まる特殊なコメントにreferenceタグを書くことで、別ファイルに定義された型情報を参照することができるようになっている。

  • util/strings.ts
function trimLeft(str: string): string {
    return str.replace(/^\s+/, '');
}
  • main.ts
/// <reference path="util/strings.ts">
var input = document.getElementsByTagName('input')[0];
input.value = trimLeft(input.value);

これでmain.tsをtscコンパイルすると、main.jsとその参照先のutil/strings.jsがそれぞれ生成されるので、前述の追加したscriptタグから読めるようになる。

$ tsc main.ts

ただ、手動でscriptタグを管理していくのは人間のすることではないので、普通は--outオプションで1ファイルに連結する。

$ tsc --out out.js main.ts

連結されたout.jsは、referenceタグで指定された全てのファイルを、依存関係を解決する適切な順序で連結したファイルになっている。

  • out.js(生成結果)
function trimLeft(str) {
    return str.replace(/^\s+/, '');
}
/// <reference path="util/strings.ts" />
var input = document.getElementsByTagName('input')[0];
input.value = trimLeft(input.value);

コンパイルされたJSファイルを見れば分かる通り、これはグローバル空間を使って異なるファイル間で関数やクラスを共有しているだけなので、 TypeScriptの仕様書ではグローバルモジュール (global module) と呼ばれている。

内部モジュール

ユーティリティにtrimLeftの他にtrimRightなどが増えていったとき、さすがに全部グローバルにぶちまけるのは忍びないので、内部モジュールというグローバル空間の汚染を減らす仕組みが用意されている。

  • util/strings.ts
module app.util.strings {
    export function trimLeft(str: string): string {
        return str.replace(/^\s+/, '');
    }
    export function trimRight(str: string): string {
        return str.replace(/\s+$/, '');
    }
}
  • main.ts
/// <reference path="util/strings.ts" />
(function () {
    var input = document.getElementsByTagName('input')[0];
    input.value = app.util.strings.trimLeft(input.value);
})();
  • out.js(生成結果)
var app;
(function (app) {
    (function (util) {
        (function (strings) {
            function trimLeft(str) {
                return str.replace(/^\s+/, '');
            }
            strings.trimLeft = trimLeft;
            function trimRight(str) {
                return str.replace(/\s+$/, '');
            }
            strings.trimRight = trimRight;
        })(util.strings || (util.strings = {}));
        var strings = util.strings;
    })(app.util || (app.util = {}));
    var util = app.util;
})(app || (app = {}));
/// <reference path="util/strings.ts" />
(function () {
    var input = document.getElementsByTagName('input')[0];
    input.value = app.util.strings.trimLeft(input.value);
})();

この例では、このアプリはグローバル空間にappという変数だけを置いて、あとはそのプロパティに階層的に名前空間を定義してる。 このmoduleの使い方をTypeScriptの仕様書では内部モジュール (internal module) と呼んでる。 同種の手法はJavaScriptでも昔からやられてきた。

TypeScriptの内部モジュールのポイントは、同じモジュールを別の場所で複数回定義できるところ。 例えばutil/strings.tsとは別のファイルから、app.util.stringsモジュール内に別の関数を追加できる。定義順も関係ない。

その2: import/exportによる外部モジュール

グローバルモジュールは仕組みが単純で分かりやすいし、世に出回っている(特に初期の)入門記事だと外部ファイルを呼び出す方法についてreferenceタグのことしか触れられてないことがある。TypeScriptコンパイラ自体がグローバルモジュールで書かれているのも原因の一つかもしれない。

でもこれだと結局グローバル変数は必要だし*1、Node.jsには向かないし、ブラウザでもBower, RequireJS, Browserifyなどのエコシステムと親和性が低い。

そこで、もう一つの方法としてimport/exportを使った外部モジュール (external module) という仕組みが用意されている。 要するに、モジュール管理をCommonJSまたはAMDで行う方法。

外部モジュールで書いておけば、将来的にECMAScript 6 Moduleがブラウザで使えるようになったとき、TypeScriptの対応次第でES6 Moduleに変換して利用できる可能性が高いのも利点。

CommonJS形式にコンパイルしてNode.jsで使う

exportで外部モジュールを定義して、importrequireで外部モジュールを読み込む。

  • util/strings.ts
export function trimLeft(str: string): string {
    return str.replace(/^\s+/, '');
}
export function trimRight(str: string): string {
    return str.replace(/\s+$/, '');
}
  • main.ts
import strings = require('./util/strings');
var input = document.getElementsByTagName('input')[0];
input.value = strings.trimLeft(input.value);

グローバルモジュールと違って、referenceタグは不要。import/requireだけで外部ファイルの型定義を参照してくれる。

コンパイルには--module commonjsオプションでCommonJS形式を指定する。

$ tsc --module commonjs main.ts

コンパイルするとexports/requireを使ったCommonJS形式で書き出される。

  • util/strings.js(生成結果)
function trimLeft(str) {
    return str.replace(/^\s+/, '');
}
exports.trimLeft = trimLeft;
function trimRight(str) {
    return str.replace(/\s+$/, '');
}
exports.trimRight = trimRight;
  • main.js(生成結果)
var strings = require('./util/strings');
var input = document.getElementsByTagName('input')[0];
input.value = strings.trimLeft(input.value);

Node.jsならこのまま実行できる。簡単でしょ?

$ node ./main.js

(Node.jsのサンプルなのにdocumentとか書いてあるのは気にしないでください。。)

外部モジュールの条件

細かい仕様にも触れておくと、以下のうちひとつでも存在するファイルは外部モジュールとして扱われる。

  • トップレベルのexport宣言
  • 外部モジュールを読み込むimport宣言
  • Export Assignment(後述のexport =

逆に言うと、これらを一つも含まないファイルはグローバルモジュールになる。

ブラウザはどうする?

コンパイル時に--module amdを指定するとrequire/defineを使ったAMD形式になる。

  • util/strings.js(生成結果)
define(["require", "exports"], function(require, exports) {
    function trimLeft(str) {
        return str.replace(/^\s+/, '');
    }
    exports.trimLeft = trimLeft;
    function trimRight(str) {
        return str.replace(/\s+$/, '');
    }
    exports.trimRight = trimRight;
});
  • main.js(生成結果)
define(["require", "exports", './util/strings'], function(require, exports, strings) {
    var input = document.getElementsByTagName('input')[0];
    input.value = strings.trimLeft(input.value);
});

ブラウザから扱う場合、CommonJS & browserifyか、AMD & RequireJSのどちらかの組み合わせを使うことになる。それぞれ特徴あるのでお好みで。

ただちょっとした書き捨てのコードのためにこれらのビルド環境を整えるのは腰が重いので、割り切ってグローバルモジュールを使うのも一つの選択肢かと。

ちょっと複雑な例

外部モジュールを初めて書いていくときこれどうやるの?ってなる系の話。

export =

こんな感じで直接呼び出し可能なモジュールを定義したいとき、

// TypeScript
import trimLeft = require('./util/trimleft');
trimLeft("foo");

つまりNode.jsでいう、

// JavaScript
module.exports = function() {...};

みたいなモジュールを定義したいとき、TypeScriptではexport =っていうExport Assignments構文を使う。

// TypeScript
function trimLeft(str: string): string {
    return str.replace(/^\s+/, '');
}
export = trimLeft;

どっちかというと関数ユーティリティよりクラス定義とかで使うかな。

動的遅延ロード

Node.jsでは、ある条件のときだけif文の中でrequireするってことがまれにある。

// JavaScript
if (someCondition) {
    var foo  = require('./foo');
    foo();
}

TypeScriptでこういう動的な読み込みをする場合はこう書くらしい。

// TypeScript

// CommonJSの生のrequireのために型宣言
declare var require;

// fooの型情報だけをインポート。コンパイル後消える。
import foo = require('./foo');
if (someCondition) {
    // 実際に残る生のrequire
    var f: typeof foo  = require('./foo');
    f();
}

コンパイル結果はこうなる。

// JavaScript
if (someCondition) {
    var f = require('./foo');
    f();
}

TypeScriptのimport文は、実際にインポートした変数が変数として使われない限りコンパイル後に残らない。上記コードのtypeof fooは変数ではなく型として使われているだけなので、コンパイルしたあとに残っていない。

このあたりはTypeScriptが変数宣言空間と型宣言空間を別に管理していることと関係していて、それについては以前に詳しく書いたのでご参考まで。

if文の中では実際にrequireしないといけないので、(TypeScriptの構文ではない)CommonJSのrequireを生で書くことで動的ローディングを実現している。1行目のdeclareJavaScriptの関数としてのrequireを型エラーにしないための型宣言。

ただし、コンパイル後もそのまま残るCommonJSのrequireを生で書いちゃってるので、--module amdしたら動かない。また、Browserifyなどの静的解析系ツールとの相性も悪いので、普通はやらない方が良いと思う。

まとめ

本当は型宣言ファイル(d.ts)のパターンについて書こうと思ったのだけど、さきにこっちを書かないといけない気がして書いてみました。

あと、最近発売された「わかめ本」ことTypeScriptリファレンスを読んだらもっと分かりやすく書いてあるはずなので、ぜひこちらもどうぞ。

TypeScriptリファレンス Ver.1.0対応

TypeScriptリファレンス Ver.1.0対応

献本もらったのにまだ読んでないです。これから読みます。すみませんすみません><

*1:アプリならいいけどライブラリだと微妙