Read OSS

Emitter パイプライン:変換処理、コード生成、宣言ファイル

上級

前提知識

  • 第 1〜4 回:型チェックまでのコンパイラパイプライン全体
  • JavaScript モジュールシステム(CommonJS・ESM・AMD)の理解
  • ソースマップとコード生成の基本概念への習熟

Emitter パイプライン:変換処理、コード生成、宣言ファイル

スキャン・パース・バインディング・型チェックを経て、コンパイラの最後の仕事は出力を生成することです。JavaScript ファイル、宣言ファイル(.d.ts)、そしてソースマップが対象になります。emitter パイプラインは AST を直接たどって文字列を書き出すのではなく、変換処理 を通じて動作します。TypeScript やモダンな JavaScript の構文をターゲットとなる出力レベルへと段階的に落とし込む AST-to-AST パスのチェーンを経て、最終的な AST をテキストとして書き出す printer に渡す、という流れです。

このアーキテクチャは TypeScript コンパイラの中でも特に洗練された部分の1つです。各トランスフォーマーは「型の除去」「クラスフィールドの変換」「JSX の変換」「CommonJS モジュールへの変換」といった1つの関心事だけを担い、すべて同じ AST 表現を操作するため、きれいに組み合わせることができます。

emitFiles とトランスフォーマーチェーン

emit パイプラインは src/compiler/emitter.tsemitFiles() から始まります。この関数は EmitResolver(checker の emit 向け API)・EmitHost・スクリプト用と宣言用の両トランスフォーマーを含む EmitTransformers オブジェクト・各種フラグを受け取ります。

flowchart LR
    Program["program.emit()"] --> EF["emitFiles()"]
    EF --> GT["getTransformers(options)"]
    GT --> Chain["Transformer Chain"]
    Chain --> T1["transformTypeScript"]
    T1 --> T2["transformJsx"]
    T2 --> T3["transformESNext"]
    T3 --> T4["transformClassFields"]
    T4 --> T5["transformES20xx"]
    T5 --> T6["transformModule"]
    T6 --> Printer["Printer → text output"]
    Printer --> JS[".js file"]
    Printer --> Map[".js.map file"]
    
    EF --> DT["Declaration Transforms"]
    DT --> TD["transformDeclarations"]
    TD --> DPrinter["Printer → .d.ts output"]

src/compiler/transformer.ts にある getTransformers() 関数は、コンパイラオプションに基づいて順序付きのチェーンを組み立てます。この順序は非常に重要です。各トランスフォーマーは元のソースからある種の構文がまだ残っていることを前提としており、後のトランスフォーマーは前のトランスフォーマーがすでに対象の構文を変換済みであることを期待しています。

function getScriptTransformers(compilerOptions, customTransformers, emitOnly) {
    const transformers = [];
    addRange(transformers, customTransformers?.before);

    transformers.push(transformTypeScript);  // Always first: strip types

    if (compilerOptions.experimentalDecorators) {
        transformers.push(transformLegacyDecorators);
    }
    if (getJSXTransformEnabled(compilerOptions)) {
        transformers.push(transformJsx);
    }
    // ESNext, ESDecorators, ClassFields...
    // Then progressive downleveling: ES2021 → ES2020 → ... → ES2015
    if (languageVersion < ScriptTarget.ES2015) {
        transformers.push(transformES2015);
        transformers.push(transformGenerators);
    }

    transformers.push(getModuleTransformer(moduleKind));  // Always last

    addRange(transformers, customTransformers?.after);
    return transformers;
}

ヒント: ts-patch や ttypescript といったビルドツールを通じたカスタムトランスフォーマープラグインは、customTransformers.beforecustomTransformers.after の配列を介してチェーンに組み込まれます。組み込みの変換処理と正しく連携するプラグインを書くには、このチェーンの順序を把握しておくことが不可欠です。

主要なトランスフォーマー:型の除去・モジュール・JSX・ダウンレベリング

TypeScript → JavaScript 変換

transformTypeScript は常にチェーンの先頭に置かれます。主な処理は次のとおりです。

  • 型アノテーションの除去:型参照・型アサーション・as 式・型のみの import/export をすべて削除します
  • enum の変換enum Color { Red, Green, Blue } は、前方マッピングと逆方向マッピングの両方を持つオブジェクトを構築する IIFE に変換されます
  • namespace の変換namespace Foo { ... }Foo オブジェクトを生成する IIFE に変換されます
  • パラメータプロパティの処理constructor(public x: number) に対して this.x = x の代入文が生成されます
  • declare ブロックの削除:アンビエント宣言は出力を生成しません

モジュール変換

getModuleTransformer() でのモジュールトランスフォーマーの選択は、moduleKind によって分岐します。

flowchart TD
    MK["moduleKind"] --> Preserve["Preserve → transformECMAScriptModule"]
    MK --> Node["ESNext/ES2022/ES2020/ES2015/Node16/Node18/Node20/NodeNext/CommonJS → transformImpliedNodeFormatDependentModule"]
    MK --> System["System → transformSystemModule"]
    MK --> Default["AMD/UMD/Default → transformModule"]

transformImpliedNodeFormatDependentModule は特に興味深い存在です。transformModule(CJS 出力)と transformECMAScriptModule(ESM 出力)の両方をラップし、各ファイルの impliedNodeFormat に応じていずれかを選択します。これにより --module nodenext は、同一コンパイル内で .cts ファイルには CJS を、.mts ファイルには ESM を出力できるのです。

メインとなる transformModule は CommonJS・AMD・UMD の出力を担い、import/export 文を require() 呼び出しや Object.defineProperty(exports, ...) への代入、AMD/UMD のファクトリラッパーへと変換します。

JSX 変換

transformJsx は JSX 構文を関数呼び出しに変換します。--jsx react では <div className="x">React.createElement("div", { className: "x" }) になります。--jsx react-jsx では JSX ランタイムを auto-import しつつ _jsx("div", { className: "x" }) になります。

クラスフィールドとデコレータ

transformClassFields は、useDefineForClassFields[[Define]][[Set]] セマンティクスの相互作用を含む、クラスフィールド宣言の複雑な変換を担います。transformESDecorators は TC39 stage 3 のデコレータ提案を実装し、transformLegacyDecorators は従来の --experimentalDecorators の挙動を担当します。

ES ダウンレベリング

transformES20xx ファミリーは、モダンな構文を古いターゲットへと段階的に変換します。

トランスフォーマー 変換対象
transformESNext 安定したターゲットにまだ含まれていない最新の提案
transformES2021 論理代入演算子(??=||=&&=
transformES2020 オプショナルチェーン(?.)・nullish 合体演算子(??
transformES2019 Array.flat・optional catch binding
transformES2018 非同期イテレーション・オブジェクトの rest/spread
transformES2017 async/await → Promise チェーン
transformES2016 べき乗演算子(**
transformES2015 アロー関数・クラス・分割代入・for-of・テンプレートリテラル
transformGenerators ジェネレータ関数 → ステートマシン

各変換処理は、ターゲットが対応する ES レベルより下の場合にのみチェーンに組み込まれます。ES2020 をターゲットにしている場合、ES2020 以降のトランスフォーマーはすべてスキップされます。

宣言ファイルの生成

宣言ファイル(.d.ts)は別のトランスフォームチェーンで生成されます。transformDeclarations は AST を走査しながら次の処理を行います。

  1. 実装本体の除去:関数本体は ; に置き換えられ、クラスのメソッド本体は削除されます
  2. 型シグネチャの保持:パラメータ型・戻り値型・プロパティ型・ジェネリック制約を残します
  3. 推論型の解決:関数に戻り値型アノテーションがない場合、トランスフォーマーは checker にコールバックして推論された型を取得し、expressionToTypeNode を使って AST の型ノードとして具体化します
  4. 可視性によるフィルタリング:エクスポートされた宣言のみが出力に含まれます
  5. re-export の処理export { Foo } from './bar' は元の宣言まで追跡する必要があります
flowchart TD
    SF["SourceFile AST"] --> DT["transformDeclarations"]
    DT --> Strip["Strip function bodies"]
    DT --> Visibility["Filter: only exported"]
    DT --> Infer["Resolve inferred types"]
    Infer --> E2TN["expressionToTypeNode()"]
    E2TN --> Checker["Checker: getTypeOfSymbol()"]
    Checker --> TypeNode["TypeNode AST"]
    DT --> DTS[".d.ts SourceFile"]
    DTS --> Printer["Printer → .d.ts text"]

第 4 回で紹介した expressionToTypeNode の橋渡しは、ここで欠かせない役割を果たします。次の例を見てみましょう。

export function getConfig() {
    return { debug: true, level: 3 };
}

ソースには戻り値型アノテーションがありませんが、.d.ts には export function getConfig(): { debug: boolean; level: number; } という形で含まれなければなりません。宣言トランスフォーマーは checker に推論された戻り値型を問い合わせ、内部の Type オブジェクトを AST の型ノードへと変換します。

ヒント: 宣言の emit に関する問題(特に isolatedDeclarations 絡み)を調査するときは、transformDeclarations → checker コールバック → expressionToTypeNode という経路を追いましょう。TypeScript 5.5 で導入された isolatedDeclarations 機能はこのパターンを制限するもので、宣言の emit が checker を一切必要としないよう、明示的なアノテーションを求めます。

emit ヘルパーとソースマップ

ランタイムヘルパー

トランスフォーマーが構文を変換する際、ランタイムのヘルパー関数が必要になることがあります。たとえば、async/await の変換には __awaiter、クラス継承の変換には __extends、rest パラメータの変換には __rest がそれぞれ必要です。

src/compiler/factory/emitHelpers.ts モジュールはこれらのヘルパーを定義しています。各ヘルパーは EmitHelper オブジェクトとして表現され、名前・テキスト(ヘルパー関数の本体)・スコープの有無を示すフラグを持ちます。

ヘルパーを出力に含める方法は二つあります。

  1. インライン:必要な各出力ファイルにヘルパー関数を直接書き出します。これがデフォルトの挙動です。
  2. 外部参照(--importHelperstslib npm パッケージからヘルパーを import します。多数の出力ファイルにヘルパーコードが重複することを防げます。

トランスフォーマーは context.requestEmitHelper() でヘルパーを要求し、emitter はシリアライズ中にそれらを収集します。

ソースマップの生成

src/compiler/sourcemap.ts モジュールはソースマップの生成を実装しています。元の TypeScript ソース上の位置と、emit された JavaScript 上の位置の対応関係を追跡するものです。printer が変換済み AST を走査しながら出力テキストを書き出す際、元の位置情報を持つ各ノードについてソースマップのエントリを記録します。

ソースマップジェネレータは Base64 VLQ エンコードされたマッピングを持つ標準的な V3 形式のソースマップを生成します。ソースマップは独立した .js.map ファイルとして出力(--sourceMap)することも、データ URI として JavaScript 出力にインライン化(--inlineSourceMap)することもできます。宣言ファイルについては、--declarationMap を指定すると .d.ts.map ファイルが生成され、.d.ts から元の .ts ソースへのマッピングが記録されます。これは「定義へジャンプ」で .d.ts から実際のソースに移動するために欠かせない機能です。

flowchart LR
    Original["Original AST<br/>(with positions)"] --> Transforms["Transform Chain"]
    Transforms --> Transformed["Transformed AST<br/>(with original pointers)"]
    Transformed --> Printer["Printer"]
    Printer --> JS["JavaScript Text"]
    Printer --> SMG["SourceMapGenerator"]
    SMG --> SM[".js.map<br/>(VLQ mappings)"]

ここで重要なのは、変換後の各ノードが original ポインタを通じて派生元のソースノードを参照している点です(NodeFactory.update* メソッドによって設定されます)。変換処理を経て AST の構造が大きく変わっていても、printer はこのポインタを使って正確なソース位置をマップに記録できます。

次回予告

これでコンパイルパイプラインの全体像をたどり終えました。ソーステキストから始まり、スキャン・パース・バインディング・型チェック・変換・emit という流れを順に見てきました。しかし TypeScript コンパイラはバッチ処理ツールにとどまらず、日々使う IDE のあらゆる機能を支えています。第 6 回では Language Service と tsserver を取り上げます。補完・定義へジャンプ・参照検索・リファクタリング・コード修正がコンパイラコアの上にどのように構築されているか、そしてサーバープロセスがワークスペース内の複数プロジェクトをどのように管理するかを詳しく見ていきましょう。