TypeScript

TypeScript Handbook を読む (16. Module Resolution)

TypeScript Handbook を読み進めていく第十六回目。

  1. Basic Types
  2. Variable Declarations
  3. Interfaces
  4. Classes
  5. Functions
  6. Generics
  7. Enums
  8. Type Inference
  9. Type Compatibility
  10. Advanced Types
  11. Symbols
  12. Iterators and Generators
  13. Modules
  14. Namwspaces
  15. Namespaces and Modules
  16. Module Resolution (今ココ)
  17. Declaration Merging
  18. JSX
  19. Decorators
  20. Mixins
  21. Triple-Slash Directives
  22. 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.tsimport { b } from "./moduleB" と記述した場合、以下のファイルが検索対象となります。

  1. /root/src/folder/moduleB.ts
  2. /root/src/folder/moduleB.d.ts

非相対インポートの場合、ソースファイルを含むディレクトリからディレクトリツリーを遡ってインポート対象のファイルを検索します。
例えば /root/src/folder/A.tsimport { b } from "moduleB" と記述した場合、以下のファイルが検索対象となります。

  1. /root/src/folder/moduleB.ts
  2. /root/src/folder/moduleB.d.ts
  3. /root/src/moduleB.ts
  4. /root/src/moduleB.d.ts
  5. /root/moduleB.ts
  6. /root/moduleB.d.ts
  7. /moduleB.ts
  8. /moduleB.d.ts

Node

この方式は Node.js のモジュール解決方式を模倣したものであり、アルゴリズム全体の説明は Node.js のドキュメント に記載されています。

How Node.js resolves modules

伝統的に、Node.js でのインポートは require 関数を呼び出すことで行われます。
この時、require に相対パスと非相対パスのどちらを指定するかによって Node.js の振る舞いが変わります。

相対パスを指定した場合は単純明快で、例えば /root/src/moduleA.jsvar x = require("./moduleB"); とした場合、Node.js は次の順番でモジュールの解決を試みます。

  1. /root/src/moduleB.js
  2. /root/src/moduleB/package.json"main" モジュールに指定されたパス
    (例: { "main": "lib/mainModule.js" } の場合、/root/src/moduleB/lib/mainModule.js)
  3. /root/src/moduleB/index.js

より詳しい情報は Node.js の ファイルモジュールフォルダモジュール のドキュメントを参照してください。

非相対パス を指定した場合、モジュール解決時に node_modules フォルダを検索するようになります。
node_modules フォルダはソースファイルと同じ階層か、より上位のフォルダに配置することができ、Node はそれらのフォルダを対象のモジュールが見つかるまで下から順番に検索していきます。

例えば /root/src/moduleA.jsvar x = require("moduleB"); とした場合、moduleB は以下の順で検索されます。

  1. /root/src/node_modules/moduleB.js
  2. /root/src/node_modules/moduleB/package.json ("main" プロパティが指定されている場合)
  3. /root/src/node_modules/moduleB/index.js
  4. /root/node_modules/moduleB.js
  5. /root/node_modules/moduleB/package.json ("main" プロパティが指定されている場合)
  6. /root/node_modules/moduleB/index.js
  7. /node_modules/moduleB.js
  8. /node_modules/moduleB/package.json ("main" プロパティが指定されている場合)
  9. /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.tsimport { b } from "./moduleB" とした場合、`"./moduleB" は以下の場所から検索されます。

  1. /root/src/moduleB.ts
  2. /root/src/moduleB.tsx
  3. /root/src/moduleB.d.ts
  4. /root/src/moduleB/package.json("types" プロパティが指定されている場合)
  5. /root/src/moduleB/index.ts
  6. /root/src/moduleB/index.tsx
  7. /root/src/moduleB/index.d.ts

非相対パスの解決は Node.js と似ています。
/root/src/moduleA.tsimport { b } from "moduleB" とした場合、以下の順で検索が行われます。

  1. /root/src/node_modules/moduleB.ts
  2. /root/src/node_modules/moduleB.tsx
  3. /root/src/node_modules/moduleB.d.ts
  4. /root/src/node_modules/moduleB/package.json ("types" プロパティが指定されている場合)
  5. /root/src/node_modules/moduleB/index.ts
  6. /root/src/node_modules/moduleB/index.tsx
  7. /root/src/node_modules/moduleB/index.d.ts
  8. /root/node_modules/moduleB.ts
  9. /root/node_modules/moduleB.tsx
  10. /root/node_modules/moduleB.d.ts
  11. /root/node_modules/moduleB/package.json ("types" プロパティが指定されている場合)
  12. /root/node_modules/moduleB/index.ts
  13. /root/node_modules/moduleB/index.tsx
  14. /root/node_modules/moduleB/index.d.ts
  15. /node_modules/moduleB.ts
  16. /node_modules/moduleB.tsx
  17. /node_modules/moduleB.d.ts
  18. /node_modules/moduleB/package.json ("types" プロパティが指定されている場合)
  19. /node_modules/moduleB/index.ts
  20. /node_modules/moduleB/index.tsx
  21. /node_modules/moduleB/index.d.ts

ステップが多いように見えますが、実際にはステップ (8) と (15) の 2 回しかディレクトリを遡っておらず、Node.js と比べても特に複雑というわけではありません。

実際には、ディレクトリごとに モジュール名と同じファイル名のファイルpakage.jsonindex.* の順で定義ファイルを検索してるだけ

Additional module resolution flags

出力ファイルのディレクトリ構成は必ずしもソースファイルのディレクトリ構成と同じとは限りません。
例えば、.ts ファイルを .js にコンパイルした後に依存ファイルとまとめて一つの場所に出力することもあるでしょう。
その場合、モジュールはソースファイルとは別の名前になる他、モジュールの場所もソースファイルのあった場所とは別の場所になるでしょう。

TypeScript コンパイラには最終出力ファイルを生成する際に使用する情報を渡すためのフラグが用意されています。

ただし、これらのフラグはソースファイルの変換には影響 しない ことに注意してください。
あくまでもモジュール定義ファイルの場所を解決するための参考情報として使用されるだけです。

Base URL

baseUrl は AMD モジュールローダーを使用し、各モジュールを同じフォルダに "デプロイ" する場合によく使用されます。
その場合、ソースファイルが異なる場所にあってもコンパイル結果のファイルは同じフォルダへ出力されます。

baseUrl を指定すると、非相対パスで指定されたインポートはすべて baseUrl からの相対パスとして扱われます。

baseUrl の値は以下のいずれかの方法で指定可能です。

  • baseUrl コマンドライン引数
    (相対パスが指定された場合、カレントディレクトリからの相対パスとみなされます)
  • 'tsconfig.json' の baseUrl プロパティ
    (相対パスが指定された場合、'tsconfig.json' の場所からの相対パスとみなされます)

相対パスでのインポートは baseUrl の設定の影響を受けません。
相対パスでのインポートは常にインポート元のファイルからの相対パスとなります。

より詳しくは RequireJSSystemJS のドキュメントを参照してください。

Path mapping

場合によってはモジュールが baseUrl 配下に直接配置されているとは限らないこともあるでしょう。
例えば "jquery" モジュールが "node_modules/jquery/dist/jquery.slim.min.js" に配置されているような場合です。
この場合、モジュールローダーはモジュール名とファイルの場所とのマッピング情報を使用します。
詳しくは RequireJSSystemJS の情報を参照してください。

TypeScript では tsconfig.json"paths" プロパティを使用してこのマッピング情報を定義することが可能です。
先ほどの jquery の例であれば、以下のように記述します。

tsconfig.json
{
  "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 は以下のようになります。

tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "*": [
        "*",
        "generated/*"
      ]
    }
  }
}

これにより、コンパイラは "*" に一致するモジュール (つまり全モジュール) を次の 2 箇所からインポートするようになります。

  • "*": モジュール名をそのまま使用する
    <moduleName> => <baseUrl>/<moduleName>
  • "generated/*": モジュール名の前に "generated" を付与する
    <moduleName> => <baseUrl>/generated/<moduleName>

そのため、'folder1/file2' と 'folder2/file3' のインポートはそれぞれ以下のように解決されます。

  • 'folder1/file2'

    1. パターン '*' に一致する
    2. 最初のマッピングにモジュール名を代入する: '*' -> folder1/file2
    3. 代入結果が非相対パスのため、baseUrl を付与する: projectRoot/folder1/file2.ts
    4. ファイルが存在するため、ここで終了
  • 'folder2/file3'

    1. パターン '*' に一致する
    2. 最初のマッピングにモジュール名を代入する: '*' -> folder2/file3
    3. 代入結果が非相対パスのため、baseUrl を付与する: projectRoot/folder2/file3
    4. ファイルが存在しないため、次のマッピングに移る
    5. 2 番目のマッピングにモジュール名を代入する: 'generated/*' -> generated/folder2/file3
    6. 代入結果が非相対パスのため、baseUrl を付与する: projectRoot/generated/folder2/file3.ts
    7. ファイルが存在するため、ここで終了

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 は以下のようになります。

tsconfig.json
{
  "compilerOptions": {
    "rootDirs": [
      "src/views",
      "generated/templates/views"
    ]
  }
}

こうすることで、相対パスでモジュールをインポートした場合に rootDirs に指定した各フォルダ配下も検索対象となります。

rootDirs は非常に柔軟であり、ディレクトリの有無によらず、任意の数/名前のディレクトリを指定することが可能です。
これにより、条件付きインクルードやプロジェクト固有のローダープラグインといった、より高度なビルド方法や実行時機能を使用できるようになります。

ここでプログラムの国際化の例を考えてみましょう。
ビルドツールはロケール固有のコードを特殊なトークン (ここでは #{locale}) を含むパスに自動生成するものとします。
つまり、この時のモジュールの相対パスは ./#{locale}/messages のようになります。
このツールを使用したとすると、対応しているロケールに従って ./zh/messages./de/messages といったモジュールが生成されます。

この時に生成されるモジュールは文字列の配列をエクスポートしているものとします。
例えば、./zh/messages であれば以下のような内容が出力されるとします。

./zh/messages.ts
export default [
    "您好吗",
    "很高兴认识你"
];

rootDirs を使用することで、(実際にはそのようなパスが存在しないにも関わらず) ./#{locale}/messages を安全に解決することが可能です。
この時の tsconfig.json は以下のようになります。

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.tsimport * 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 オプションを指定することで、コマンドラインで指定されていないファイルをモジュール解決の対象に含めないようにすることができます。

例を見てみましょう。

app.ts
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="..." /> ディレクティブでインポートしている すべての ファイルを除外する必要があります。