TypeScript Handbook を読み進めていく第十六回目。
- Basic Types
- Variable Declarations
- Interfaces
- Classes
- Functions
- Generics
- Enums
- Type Inference
- Type Compatibility
- Advanced Types
- Symbols
- Iterators and Generators
- Modules
- Namwspaces
- Namespaces and Modules
- Module Resolution (今ココ)
- Declaration Merging
- JSX
- Decorators
- Mixins
- Triple-Slash Directives
- Type Checking JavaScript Files
Module Resolution
モジュールの解決 はコンパイラがインポート対象を見つけ出すためのプロセスです。
例えば import { a } from "moduleA"
というインポート文があった場合に、a
を使用するためには moduleA
の定義を参照し、それが何であるか調べる必要があります。
コンパイラは Classic 方式と Node 方式の 2 通りの方法でインポートされたモジュールの場所を検索します。
もし検索に失敗し、かつ、モジュール名が相対パスでない ("moduleA" といった表記である) 場合には、アンビエントモジュール宣言 の検索を試みます。
それでもモジュールを解決できなかった場合、error TS2307: Cannot find module 'moduleA'
といったエラーが出力されます。
Relative vs. Non-relative module imports
モジュールの参照先が相対パスかどうかに応じて、異なる方法でモジュールの解決が行われます。
相対インポート とは、以下のように /
、./
、../
のいずれかから始まるものを指します。
import Entry from "./components/Entry";
import { DefaultHeaders } from "../constants/http";
import "/mod";
以下のような、それ以外のインポート文は 非相対インポート として扱われます。
import * as $ from "jquery";
import { Component } from "@angular/core";
相対インポートはインポート対象ファイルのパスを相対的に解決しようとするため、アンビエントモジュール宣言は検索 されません。
そのため、ファイルの場所を自分で管理できる自作モジュールに対して使用するべきです。
非相対インポートは baseUrl
からの相対パスが用いられる他、アンビエントモジュール宣言 も検索対象となります。
そのため、非相対インポートは外部ライブラリをインポートする時に使用してください。
Module Resolution Strategies
モジュールの解決方法には Node 方式と Classic 方式の 2 通りの方法が存在します。
どちらを使用するかは --moduleResolution
フラグを使用することで指定可能です。
何も指定しなかった場合、--module AMD | System | ES2015
の場合は Classic 方式が、それ以外の場合は Node 方式が採用されます。
--module
のデフォルトはtarget === "ES6" ? "ES6" : "CommonJS"
で、--target
のデフォルトは"ES3"
なので、何も考えなければ Node 方式が採用される
Classic
これは後方互換性のために残されている方式です。
この方式では、相対インポートはインポート元のファイルからの相対パスを検索します。
つまり、/root/src/folder/A.ts
で import { b } from "./moduleB"
と記述した場合、以下のファイルが検索対象となります。
/root/src/folder/moduleB.ts
/root/src/folder/moduleB.d.ts
非相対インポートの場合、ソースファイルを含むディレクトリからディレクトリツリーを遡ってインポート対象のファイルを検索します。
例えば /root/src/folder/A.ts
で import { b } from "moduleB"
と記述した場合、以下のファイルが検索対象となります。
/root/src/folder/moduleB.ts
/root/src/folder/moduleB.d.ts
/root/src/moduleB.ts
/root/src/moduleB.d.ts
/root/moduleB.ts
/root/moduleB.d.ts
/moduleB.ts
/moduleB.d.ts
Node
この方式は Node.js のモジュール解決方式を模倣したものであり、アルゴリズム全体の説明は Node.js のドキュメント に記載されています。
How Node.js resolves modules
伝統的に、Node.js でのインポートは require
関数を呼び出すことで行われます。
この時、require
に相対パスと非相対パスのどちらを指定するかによって Node.js の振る舞いが変わります。
相対パスを指定した場合は単純明快で、例えば /root/src/moduleA.js
で var x = require("./moduleB");
とした場合、Node.js は次の順番でモジュールの解決を試みます。
/root/src/moduleB.js
-
/root/src/moduleB/package.json
の"main"
モジュールに指定されたパス
(例:{ "main": "lib/mainModule.js" }
の場合、/root/src/moduleB/lib/mainModule.js
) /root/src/moduleB/index.js
より詳しい情報は Node.js の ファイルモジュール と フォルダモジュール のドキュメントを参照してください。
非相対パス を指定した場合、モジュール解決時に node_modules
フォルダを検索するようになります。
node_modules
フォルダはソースファイルと同じ階層か、より上位のフォルダに配置することができ、Node はそれらのフォルダを対象のモジュールが見つかるまで下から順番に検索していきます。
例えば /root/src/moduleA.js
で var x = require("moduleB");
とした場合、moduleB
は以下の順で検索されます。
/root/src/node_modules/moduleB.js
-
/root/src/node_modules/moduleB/package.json
("main"
プロパティが指定されている場合) /root/src/node_modules/moduleB/index.js
/root/node_modules/moduleB.js
-
/root/node_modules/moduleB/package.json
("main"
プロパティが指定されている場合) /root/node_modules/moduleB/index.js
/node_modules/moduleB.js
-
/node_modules/moduleB/package.json
("main"
プロパティが指定されている場合) /node_modules/moduleB/index.js
より詳しい情報は Node.js のドキュメントの node_modules
からのモジュール読み込み を参照してください。
How TypeScript resolves modules
TypeScript はコンパイル時に定義ファイルの場所を解決するために Node.js のモジュール解決方式を模倣しており、違いは拡張子が .ts
、.tsx
、.d.ts
のソースファイルも検索対象とする点です。
また、package.json
について、"main"
プロパティと同じ用途で "types"
プロパティを使用します。
例えば、/root/src/moduleA.ts
で import { b } from "./moduleB"
とした場合、`"./moduleB" は以下の場所から検索されます。
/root/src/moduleB.ts
/root/src/moduleB.tsx
/root/src/moduleB.d.ts
-
/root/src/moduleB/package.json
("types"
プロパティが指定されている場合) /root/src/moduleB/index.ts
/root/src/moduleB/index.tsx
/root/src/moduleB/index.d.ts
非相対パスの解決は Node.js と似ています。
/root/src/moduleA.ts
で import { b } from "moduleB"
とした場合、以下の順で検索が行われます。
/root/src/node_modules/moduleB.ts
/root/src/node_modules/moduleB.tsx
/root/src/node_modules/moduleB.d.ts
-
/root/src/node_modules/moduleB/package.json
("types"
プロパティが指定されている場合) /root/src/node_modules/moduleB/index.ts
/root/src/node_modules/moduleB/index.tsx
/root/src/node_modules/moduleB/index.d.ts
/root/node_modules/moduleB.ts
/root/node_modules/moduleB.tsx
/root/node_modules/moduleB.d.ts
-
/root/node_modules/moduleB/package.json
("types"
プロパティが指定されている場合) /root/node_modules/moduleB/index.ts
/root/node_modules/moduleB/index.tsx
/root/node_modules/moduleB/index.d.ts
/node_modules/moduleB.ts
/node_modules/moduleB.tsx
/node_modules/moduleB.d.ts
-
/node_modules/moduleB/package.json
("types"
プロパティが指定されている場合) /node_modules/moduleB/index.ts
/node_modules/moduleB/index.tsx
/node_modules/moduleB/index.d.ts
ステップが多いように見えますが、実際にはステップ (8) と (15) の 2 回しかディレクトリを遡っておらず、Node.js と比べても特に複雑というわけではありません。
実際には、ディレクトリごとに
モジュール名と同じファイル名のファイル
→pakage.json
→index.*
の順で定義ファイルを検索してるだけ
Additional module resolution flags
出力ファイルのディレクトリ構成は必ずしもソースファイルのディレクトリ構成と同じとは限りません。
例えば、.ts
ファイルを .js
にコンパイルした後に依存ファイルとまとめて一つの場所に出力することもあるでしょう。
その場合、モジュールはソースファイルとは別の名前になる他、モジュールの場所もソースファイルのあった場所とは別の場所になるでしょう。
TypeScript コンパイラには最終出力ファイルを生成する際に使用する情報を渡すためのフラグが用意されています。
ただし、これらのフラグはソースファイルの変換には影響 しない ことに注意してください。
あくまでもモジュール定義ファイルの場所を解決するための参考情報として使用されるだけです。
Base URL
baseUrl
は AMD モジュールローダーを使用し、各モジュールを同じフォルダに "デプロイ" する場合によく使用されます。
その場合、ソースファイルが異なる場所にあってもコンパイル結果のファイルは同じフォルダへ出力されます。
baseUrl
を指定すると、非相対パスで指定されたインポートはすべて baseUrl
からの相対パスとして扱われます。
baseUrl の値は以下のいずれかの方法で指定可能です。
-
baseUrl コマンドライン引数
(相対パスが指定された場合、カレントディレクトリからの相対パスとみなされます) - 'tsconfig.json' の baseUrl プロパティ
(相対パスが指定された場合、'tsconfig.json' の場所からの相対パスとみなされます)
相対パスでのインポートは baseUrl の設定の影響を受けません。
相対パスでのインポートは常にインポート元のファイルからの相対パスとなります。
より詳しくは RequireJS や SystemJS のドキュメントを参照してください。
Path mapping
場合によってはモジュールが baseUrl 配下に直接配置されているとは限らないこともあるでしょう。
例えば "jquery"
モジュールが "node_modules/jquery/dist/jquery.slim.min.js"
に配置されているような場合です。
この場合、モジュールローダーはモジュール名とファイルの場所とのマッピング情報を使用します。
詳しくは RequireJS や SystemJS の情報を参照してください。
TypeScript では tsconfig.json
の "paths"
プロパティを使用してこのマッピング情報を定義することが可能です。
先ほどの jquery
の例であれば、以下のように記述します。
{
"compilerOptions": {
"baseUrl": ".", // "paths" を指定する場合、必須
"paths": {
"jquery": ["node_modules/jquery/dist/jquery"] // これは "baseUrl" からの相対パス
}
}
}
"paths"
は "baseUrl"
からの相対パスとして扱われることに注意してください。
上記の例で "baseUrl": "./src"
とした場合、jquery は "../node_modules/jquery/dist/jquery"
にマッピングされます。
おそらく
./src/node_modules/jquery/dist/jquery"
の間違い
"paths"
には複数のフォールバックを指定することも可能です。
例えば、以下のようにあるモジュールだけが特定のディレクトリに配置されており、それ以外が別のディレクトリに配置されているような場合を考えてみましょう。
projectRoot
├── folder1
│ ├── file1.ts ('folder1/file2' と 'folder2/file3' をインポートしている)
│ └── file2.ts
├── generated
│ ├── folder1
│ └── folder2
│ └── file3.ts
└── tsconfig.json
この場合、tsconfig.json
は以下のようになります。
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"*": [
"*",
"generated/*"
]
}
}
}
これにより、コンパイラは "*"
に一致するモジュール (つまり全モジュール) を次の 2 箇所からインポートするようになります。
-
"*"
: モジュール名をそのまま使用する
<moduleName>
=><baseUrl>/<moduleName>
-
"generated/*"
: モジュール名の前に "generated" を付与する
<moduleName>
=><baseUrl>/generated/<moduleName>
そのため、'folder1/file2' と 'folder2/file3' のインポートはそれぞれ以下のように解決されます。
-
'folder1/file2'
- パターン '*' に一致する
- 最初のマッピングにモジュール名を代入する: '*' ->
folder1/file2
- 代入結果が非相対パスのため、baseUrl を付与する:
projectRoot/folder1/file2.ts
- ファイルが存在するため、ここで終了
-
'folder2/file3'
- パターン '*' に一致する
- 最初のマッピングにモジュール名を代入する: '*' ->
folder2/file3
- 代入結果が非相対パスのため、baseUrl を付与する:
projectRoot/folder2/file3
- ファイルが存在しないため、次のマッピングに移る
- 2 番目のマッピングにモジュール名を代入する: 'generated/*' ->
generated/folder2/file3
- 代入結果が非相対パスのため、baseUrl を付与する:
projectRoot/generated/folder2/file3.ts
- ファイルが存在するため、ここで終了
Virtual Directories with rootDirs
複数のディレクトリのソースファイルをひとつのディレクトリにまとめて出力することもあるでしょう。
これは複数のソースファイルのディレクトリを "仮想" ディレクトリのように見せかけることで実現しています。
'rootDirs' プロパティを使用し、コンパイラに "仮想" ディレクトリの 起点 を伝えます。
こうすることでコンパイラは相対パスで指定されたモジュールを "仮想" ディレクトリからの相対パスとして解決するようになります。
例として以下の構成のプロジェクトを見てみましょう。
src
└── views
└── view1.ts ('./template1' をインポートしている)
└── view2.ts
generated
└── templates
└── views
└── template1.ts ('./view2' をインポートしている)
src/views
内のファイルは UI コントロールに関するユーザコードであり、generated/templates
内のファイルはビルド時にテンプレートジェネレータにて自動生成される UI バインディング用のコードです。
また、ビルド時には /src/views
と /generated/templates/views
にあるファイルを同じ出力フォルダへコピーします。
そして、実行時にはビューとテンプレートが同じフォルダにあるものとしてインポートするため、この時の相対パスには "./template"
を指定します。
上記の例の話なら
"./template1"
じゃなかろうか
これらの関係性をコンパイラに伝えるには "rootDirs"
を使用します。
"rootDirs"
は実行時に同じフォルダにあるものとして扱う ルートフォルダ のリストを指定します。
上記の例の場合、tsconfig.json
は以下のようになります。
{
"compilerOptions": {
"rootDirs": [
"src/views",
"generated/templates/views"
]
}
}
こうすることで、相対パスでモジュールをインポートした場合に rootDirs
に指定した各フォルダ配下も検索対象となります。
rootDirs
は非常に柔軟であり、ディレクトリの有無によらず、任意の数/名前のディレクトリを指定することが可能です。
これにより、条件付きインクルードやプロジェクト固有のローダープラグインといった、より高度なビルド方法や実行時機能を使用できるようになります。
ここでプログラムの国際化の例を考えてみましょう。
ビルドツールはロケール固有のコードを特殊なトークン (ここでは #{locale}
) を含むパスに自動生成するものとします。
つまり、この時のモジュールの相対パスは ./#{locale}/messages
のようになります。
このツールを使用したとすると、対応しているロケールに従って ./zh/messages
、./de/messages
といったモジュールが生成されます。
この時に生成されるモジュールは文字列の配列をエクスポートしているものとします。
例えば、./zh/messages
であれば以下のような内容が出力されるとします。
export default [
"您好吗",
"很高兴认识你"
];
rootDirs
を使用することで、(実際にはそのようなパスが存在しないにも関わらず) ./#{locale}/messages
を安全に解決することが可能です。
この時の tsconfig.json
は以下のようになります。
{
"compilerOptions": {
"rootDirs": [
"src/zh",
"src/de",
"src/#{locale}"
]
}
}
こうすることで import messages from './#{locale}/messages'
は import messages from './zh/messages'
として解決されるため、ロケールを意識することなく開発を行うことができるようになります。
import messages from './messages'
としないとsrc/zh/#{locale}/messages
を見に行ってしまうんではなかろうか
Tracing module resolution
ここまで述べてきたように、モジュールを解決する際にカレントフォルダ以外のファイルも参照されるため、モジュールの解決に失敗した時の調査は難しくなるでしょう。
そのため、モジュール解決時の動作を出力するためのオプションとして --traceResolution
が提供されています。
例として typescript
モジュールを使用しているアプリケーションを見てみましょう。
ここで、app.ts
は import * as ts from "typescript"
と宣言しているものとします。
│ tsconfig.json
├───node_modules
│ └───typescript
│ └───lib
│ typescript.d.ts
└───src
app.ts
--traceResolution
オプションを指定してコンパイルを実行します。
tsc --traceResolution
この時の出力結果は以下のようになります。
======== Resolving module 'typescript' from 'src/app.ts'. ========
Module resolution kind is not specified, using 'NodeJs'.
Loading module 'typescript' from 'node_modules' folder.
File 'src/node_modules/typescript.ts' does not exist.
File 'src/node_modules/typescript.tsx' does not exist.
File 'src/node_modules/typescript.d.ts' does not exist.
File 'src/node_modules/typescript/package.json' does not exist.
File 'node_modules/typescript.ts' does not exist.
File 'node_modules/typescript.tsx' does not exist.
File 'node_modules/typescript.d.ts' does not exist.
Found 'package.json' at 'node_modules/typescript/package.json'.
'package.json' has 'types' field './lib/typescript.d.ts' that references 'node_modules/typescript/lib/typescript.d.ts'.
File 'node_modules/typescript/lib/typescript.d.ts' exist - use it as a module resolution result.
======== Module name 'typescript' was successfully resolved to 'node_modules/typescript/lib/typescript.d.ts'. ========
Things to look out for
- インポートしているモジュールの名前と場所
======== Resolving module ‘typescript’ from ‘src/app.ts’. ======== - モジュール解決方式
Module resolution kind is not specified, using ‘NodeJs’. - npm パッケージからの types の読み込み
‘package.json’ has ‘types’ field ‘./lib/typescript.d.ts’ that references ‘node_modules/typescript/lib/typescript.d.ts’. - 最終結果
======== Module name ‘typescript’ was successfully resolved to ‘node_modules/typescript/lib/typescript.d.ts’. ========
Using --noResolve
デフォルトではコンパイル処理の開始前にすべてのモジュールの解決が行われます。
ここでモジュールの解決に成功したファイルがコンパイル対象として扱われます。
--noResolve
オプションを指定することで、コマンドラインで指定されていないファイルをモジュール解決の対象に含めないようにすることができます。
例を見てみましょう。
import * as A from "moduleA" // OK、 'moduleA' はコマンドラインで指定されている
import * as B from "moduleB" // Error TS2307: Cannot find module 'moduleB'.
tsc app.ts moduleA.ts --noResolve
イマイチ使いドコロが分からない。
余分なファイルをコンパイルさせないことで、ビルド時間を短縮するとかかな?
Common Questions
Why does a module in the exclude list still get picked up by the compiler?
tsconfig.json
はフォルダを "プロジェクト" に変えます。
つまり、“exclude” や “files” を指定しなかった場合、tsconfig.json
を含むフォルダは、そのサブフォルダも含めてすべてコンパイル対象となります。
そのため、もしも特定のファイルを除外したい場合は “exclude”
を使用してください。
逆にコンパイル対象のファイルを指定したい場合は "files"
を使用してください。
これは tsconfig.json
の自動インクルード機構によるもので、これまでに述べてきたモジュール解決には影響しません。
そのため、コンパイラがインポート対象とみなしたファイルは tsconfig.json
の設定によらず、コンパイル対象に含まれることになります。
コンパイル対象から除外するためには、そのファイル自身と、そのファイルを import
や /// <reference path="..." />
ディレクティブでインポートしている すべての ファイルを除外する必要があります。