TypeScript

TypeScript Compiler APIとは|AST解析と型情報取得の手順とTypeScript 7対応

TypeScript Compiler APIとは|AST解析と型情報取得の手順とTypeScript 7対応

TypeScript Compiler API は、tsc コマンドが内部で使っているコンパイラ本体を、プログラムから直接呼び出すための API です。ソースコードを抽象構文木(AST)として読み、型チェッカに型を問い合わせ、書き換えた結果を出力する処理を、コマンドラインを介さずに実装できます。lint ルールの自作、コードの一括変換、型定義からのドキュメント生成といったツールがこの API の上に成り立っています。ただし npm の typescript パッケージは latest が 7.0.2 に移り、この API の姿が大きく変わりました。本記事では従来の ts.* API による実装手順を押さえたうえで、TypeScript 7 で何が変わり、いま何を選ぶべきかまで扱います。

まとめ:Compiler APIの全体像とバージョン選択

Compiler API の中心は Program・SourceFile・TypeChecker の3つです。createProgram でプロジェクト全体を表す Program を作り、そこから各ファイルの AST(SourceFile)と、型を解決する TypeChecker を取り出します。AST を走査してノードを判定し、必要なら TypeChecker に型を尋ね、Printer で書き戻す。これが解析ツールの基本形です。

バージョン選択には、はっきりした分岐があります。npm の typescript は latest が 7.0.2(JST 2026年7月9日公開)になりましたが、この 7.x は Go で書き直されたネイティブコンパイラであり、import * as ts from "typescript" で ts.createProgram を呼ぶ従来の API は同梱されていません。一方 6.0.3 は従来どおり lib/typescript.js を同梱し、ts.* API がそのまま動きます。解析ツールのエコシステムもまだ 6 系の上にあります。解析ツールを書くなら、typescript@6 系をバージョン固定で入れるのが実務上の答えです。7 系の新 API は unstable という名前空間に置かれており、名前のとおり試験段階にあります。

Compiler APIの構成要素と公式資料の位置づけ

Program・SourceFile・TypeChecker・Printerの分担とtscとの違い

Compiler API は役割の異なるオブジェクトの組み合わせです。Program はコンパイル対象のファイル群とコンパイラオプションをまとめた単位で、TypeScript でいうアプリケーション全体に相当します。SourceFile はファイル1本を表し、そのファイルの AST のルートノードそのものです。TypeChecker は識別子と型を結びつけ、「この変数の型は何か」「この代入は妥当か」に答えます。書き換えた AST を文字列に戻すのが Printer です。

tsc コマンドとの差は、粒度にあります。tsc は tsconfig.json の読み込みから型チェック、JavaScript の出力までを一括で実行する完成品です。Compiler API はその各段を個別に呼べるので、型チェックだけ走らせる、出力せずに AST だけ取る、といった組み方ができます。

どこまで API を使うかは、この分担で決まります。構文だけを見るなら SourceFile で足ります。createSourceFile は単体で AST を作れるため、Program の生成も型解決も不要です。逆に型を扱う処理は必ず Program を経由します。TypeChecker は Program からしか取得できず、Program の生成には依存ファイルの解決とディスクアクセスが伴うためです。構文チェックだけで済む処理を Program 経由で書くと、実行時間が不必要に伸びます。

CompilerHostによるファイルシステムの差し替え

Program を作るとき、コンパイラはファイルの読み込みやディレクトリの存在確認を CompilerHost というインターフェース越しに行います。createProgram にホストを渡さなければ実ファイルシステムを読むデフォルトホストが使われますが、独自の CompilerHost を渡せば、メモリ上の仮想ファイルを対象に型チェックを走らせることもできます。ブラウザで動く TypeScript プレイグラウンドや、テストコード内で型エラーを検証する仕組みは、この差し替えで実現されています。

公式ドキュメントの所在と「安定APIではない」という前提

Compiler API の一次資料は、microsoft/TypeScript-wiki リポジトリの Using-the-Compiler-API.md です。GitHub の Wiki タブからも同じ内容が読めます。同じリポジトリには、コンパイラの内部構造を扱う Architectural-Overview.md と Compiler-Internals.md もあり、パーサからバインダ、型チェッカへ至る処理の流れはこちらが詳しいです。AST のノード構造を手早く確かめたいときは、ソースを貼ると AST を可視化する ts-ast-viewer.com が実用的です。

先に押さえておくべきなのは、Using-the-Compiler-API.md の冒頭に置かれた但し書きです。「Keep in mind that this is not yet a stable API – we’re releasing this as version 0.5, and things will be changing over time.」と明記されており、Compiler API が TypeScript 本体のセマンティックバージョニングの保証対象外であるという立場が、公式に示されています。

建前ではありません。マイナーバージョンアップで AST ノードの形や関数シグネチャが動くことは実際に起きています。依存するツールでは typescript のバージョンを完全固定にし、更新時は自前のテストで確認してください。

ts.createProgramで解析環境を組み立てる手順

インストールとProgram生成の最小コード

まず typescript をバージョン固定で入れます。latest は 7.x のため、従来の API を使うなら明示指定が要ります。

npm install [email protected]

Program を作り、型チェックの結果を診断(Diagnostic)として取り出すコードは次の形になります。

import ts from "typescript";

const program = ts.createProgram(["sample.ts"], {
  strict: true,
  target: ts.ScriptTarget.ES2022,
  noEmit: true,
});

for (const d of ts.getPreEmitDiagnostics(program)) {
  if (!d.file) continue;
  const { line, character } = d.file.getLineAndCharacterOfPosition(d.start);
  const message = ts.flattenDiagnosticMessageText(d.messageText, "\n");
  console.log(`TS${d.code} ${d.file.fileName}(${line + 1},${character + 1}): ${message}`);
}

createProgram の第1引数はルートファイル名の配列、第2引数は CompilerOptions です。getPreEmitDiagnostics は構文エラーと型エラーをまとめて返します。string を number に代入するコードを対象にすると、TS2322 とともに「Type ‘string’ is not assignable to type ‘number’.」が出力されます。

diagnostic.messageTextを直接文字列として扱わない理由

診断メッセージの取り出しで最初につまずくのが messageText です。この値は string とは限らず、関連する複数のメッセージが入れ子になった DiagnosticMessageChain になることがあります。「型 A は型 B に代入できません」の下に「プロパティ x の型が違います」がぶら下がるようなケースです。messageText をそのまま文字列連結すると object という表示になるため、上のコードのように flattenDiagnosticMessageText で平坦化します。第2引数は入れ子を区切る文字列です。

tsconfig.jsonを読み込んでProgramへ渡す2段階の手順

実プロジェクトを解析対象にするなら、CompilerOptions を手書きせず tsconfig.json から読みます。読み込みは2段階で、JSON を読む readConfigFile と、それを CompilerOptions とファイルリストへ展開する parseJsonConfigFileContent に分かれています。

import ts from "typescript";
import path from "node:path";

const configPath = path.resolve("tsconfig.json");
const { config } = ts.readConfigFile(configPath, ts.sys.readFile);
const parsed = ts.parseJsonConfigFileContent(
  config,
  ts.sys,
  path.dirname(configPath),
);

const program = ts.createProgram(parsed.fileNames, parsed.options);

parseJsonConfigFileContent が include や exclude、extends の解決を担当します。この段を省いて tsconfig.json の compilerOptions をそのまま createProgram に渡すと、設定が静かに無視されるのではなく、次の例外で失敗します。

Error: target is a string value; tsconfig JSON must be parsed with
parseJsonSourceFileConfigFileContent or getParsedCommandLineOfConfigFile
before passing to createProgram

JSON では文字列で書かれる target や module を、コンパイラが数値の列挙型として受け取れないことが原因です。エラーが必要な関数名まで教えてくれるので、この文面が出たら展開処理の欠落を疑ってください。

ASTの走査とノード判定

forEachChildとgetChildrenの使い分け

AST を辿る方法は2つあり、返すものが違います。forEachChild は構文上意味のある子ノードだけを返します。getChildren はトークンまで含めた完全な子ノード列を返し、途中に SyntaxList という中間ノードが挟まります。

同じソースファイルの直下を両方で列挙すると、差がはっきり出ます。forEachChild は変数文・関数宣言・EndOfFileToken の3つを返しますが、getChildren をそのまま呼ぶと SyntaxList と EndOfFileToken の2つしか返らず、実際の文はさらに下の階層にあります。

import ts from "typescript";

const source = 'const message: string = "hello";\nfunction f(a: number) { return a; }\n';
const sf = ts.createSourceFile("in.ts", source, ts.ScriptTarget.ES2022, true);

ts.forEachChild(sf, (node) => {
  console.log(ts.SyntaxKind[node.kind]);
});

実行すると1行目は VariableStatement ではなく FirstStatement と表示されます。理由は次項のとおりで、ノードの実体は変数文のままです。解析ツールでは基本的に forEachChild を選び、getChildren はカンマや括弧の位置まで再現する整形ツールなど、トークン単位の情報が要るときに限ります。createSourceFile の第4引数(setParentNodes)を true にしているのは、false だと各ノードの parent が設定されず親を辿れないためです。

SyntaxKindの逆引きがVariableStatementではなくFirstStatementを返す問題

ノード種別のデバッグ出力で必ず引っかかるのが、SyntaxKind の逆引きです。TypeScript の SyntaxKind には範囲を示す別名が同じ数値で定義されており、VariableStatement と FirstStatement はどちらも 244 です。TypeScript の列挙型は逆引きテーブルを後勝ちで作るため、ts.SyntaxKind[244] は “FirstStatement” を返します。

この挙動を知らないと、ログに出た FirstStatement というノード種別を探して公式ドキュメントを彷徨うことになります。判定そのものは ts.isVariableStatement のような is 系ヘルパーで行い、逆引き文字列は参考表示に留めてください。なお TypeScript 7 の formatSyntaxKind は同じノードに対して “VariableStatement” を返すため、この分かりにくさは新 API では解消されています。

TypeCheckerによる型情報の取得

SymbolとTypeを取り出す手順

型を扱う処理は、ノードから Symbol を引き、Symbol から Type を引く二段構えです。Symbol は「宣言された名前」に対応する存在で、Type がその名前に付いた型です。関数宣言から関数の型を取り出すと次のようになります。

import ts from "typescript";

const program = ts.createProgram(["sample.ts"], { strict: true });
const checker = program.getTypeChecker();
const sf = program.getSourceFile("sample.ts");

ts.forEachChild(sf, (node) => {
  if (!ts.isFunctionDeclaration(node) || !node.name) return;
  const symbol = checker.getSymbolAtLocation(node.name);
  const type = checker.getTypeOfSymbolAtLocation(symbol, node);
  console.log(symbol.getName(), checker.typeToString(type));
});

greet(name: string): string という関数に対して、typeToString は (name: string) => string を返します。getSymbolAtLocation に渡すのが宣言ノードそのものではなく node.name である点に注意してください。Symbol は名前に紐づくため、FunctionDeclaration ノードを直接渡すと undefined が返ります。

isTypeAssignableToによる代入可能性の判定

typeToString の結果を文字列比較に使うのは避けてください。型引数の表示揺れやエイリアスの展開有無で結果が変わるうえ、構造的部分型の判定ができません。代入可能かどうかは checker.isTypeAssignableTo、ユニオン型やリテラル型の判別は type.isUnion() や type.isStringLiteral() を使います。typeToString はログとエラーメッセージの生成に限定するのが安全です。

Transformerによるコード変換とPrinterでの出力

visitEachChildによるノードの再帰的な置き換え

コード変換は、AST を訪問しながら新しいノードを返す TransformerFactory を書き、ts.transform に渡します。TypeScript の AST は不変(immutable)として扱う設計なので、既存ノードを書き換えず、置き換え後のノードを factory から作って返します。

import ts from "typescript";

const sf = ts.createSourceFile(
  "in.ts",
  'const message = "hello";\n',
  ts.ScriptTarget.ES2022,
  true,
);

const renamer: ts.TransformerFactory<ts.SourceFile> = (context) => (root) => {
  const visit = (node: ts.Node): ts.Node => {
    if (ts.isIdentifier(node) && node.text === "message") {
      return context.factory.createIdentifier("greeting");
    }
    return ts.visitEachChild(node, visit, context);
  };
  return ts.visitNode(root, visit, ts.isSourceFile);
};

const result = ts.transform(sf, [renamer]);
const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
console.log(printer.printFile(result.transformed[0]));
result.dispose();

置き換えたいノードに一致したら新しいノードを返し、一致しなければ visitEachChild で子へ降りる。この二択が変換処理の骨格です。visitEachChild を呼び忘れると、そのノードより下が走査されず変換が抜けます。処理後の result.dispose() で、変換中に確保された内部リソースを解放します。

printFile出力でのフォーマット非保持

printFile が返すのは AST から再構築されたコードで、元ファイルの整形は保存されません。実際に通すと連続する空行は1行に潰れ、6スペースのインデントは4スペースへ正規化されます。コメントは保持されます。

既存コードベースへ機械的なパッチを当てる用途では、この差分の広がりがレビューの妨げになります。整形の維持が要件なら、Printer で全体を出力するのではなく、変更対象ノードの位置(getStart と getEnd)を取得して元テキストの該当範囲だけを文字列置換する方が、生成される差分は小さく収まります。

TypeScript 7でのCompiler APIの変更点

[email protected]に同梱されないts.createProgram

ここが本記事で最も重要な変更点です。npm の typescript は 7.0.2 が公開され、dist-tags の latest がこのバージョンになりました。7.x は microsoft/typescript-go リポジトリから typescript/v7.0.2 というタグでリリースされた、TypeScript 本体を Go へ移植したネイティブコンパイラです。

パッケージの中身は 6.x と別物です。7.0.2 の package.json の exports を見ると、ルート “.” に割り当てられているのは ./lib/version.cjs だけで、従来の lib/typescript.js と lib/typescript.d.ts は同梱されていません。API は12本のサブパスに分割されています。

"./unstable/sync":         "./dist/api/sync/api.js"
"./unstable/async":        "./dist/api/async/api.js"
"./unstable/ast":          "./dist/ast/index.js"
"./unstable/ast/is":       "./dist/ast/is.js"
"./unstable/ast/factory":  "./dist/ast/factory.generated.js"
"./unstable/ast/visitor":  "./dist/ast/visitor.js"

上記のほか ./unstable/ast/utils、./unstable/ast/scanner、./unstable/ast/clone、./unstable/fs、./unstable/proto があります。つまり import * as ts from “typescript” と書いて ts.createProgram を呼ぶコードは、typescript@7 では動きません。名前空間が unstable であることも設計者の意思表示です。パッケージには optionalDependencies として20プラットフォーム分のネイティブバイナリ(@typescript/typescript-darwin-arm64 など)が並び、vscode-jsonrpc が vendor ディレクトリに同梱されています。JavaScript 側の API は Go 製の実行ファイルを起動し、JSON-RPC で通信するクライアントとして動く構造です。型定義のコメントにも「Path to the tsgo executable. Defaults to the bundled tsgo binary.」と記載されています。

unstable/syncでの解析コードの書き方

新 API では、まず API インスタンスを作り、対象の tsconfig.json を開いてスナップショットを取得します。スナップショットから Project を取り出すと、その中に program・checker・emitter が入っています。

import { API } from "typescript/unstable/sync";
import { isFunctionDeclaration } from "typescript/unstable/ast/is";

const api = new API({ cwd: process.cwd() });
const snapshot = api.updateSnapshot({ openProjects: ["/path/to/tsconfig.json"] });
const project = snapshot.getProject("/path/to/tsconfig.json");

for (const d of project.program.getSemanticDiagnostics()) {
  console.log(`TS${d.code} ${d.fileName}:${d.pos} ${d.text}`);
}

const sf = project.program.getSourceFile("/path/to/sample.ts");
sf.forEachChild((node) => {
  if (!isFunctionDeclaration(node) || !node.name) return;
  const symbol = project.checker.getSymbolAtLocation(node.name);
  console.log(symbol.name, project.checker.typeToString(project.checker.getTypeOfSymbol(symbol)));
});

snapshot.dispose();
api.close();

getSymbolAtLocation や typeToString といった名前は残っていますが、到達経路と周辺の作法は変わりました。差分の要点は4つです。

観点 TypeScript 6系(ts.*) TypeScript 7系(unstable/sync)
入口 ts.createProgram(fileNames, options) new API() → updateSnapshot({ openProjects })
診断メッセージ messageText + flattenDiagnosticMessageText text(文字列)
子ノード走査 ts.forEachChild(node, cb) node.forEachChild(cb)
後始末 result.dispose() のみ(プロセスを持たない) api.close() で子プロセスを解放

後始末の作法が増えたのは、コンパイラが別プロセスで動くようになったためです。api.close() を呼ぶまで、API インスタンス1つにつき tsgo の子プロセスが1つ生き続けます。親の Node プロセスが終われば回収されるので短時間のスクリプトでは表面化しませんが、同一プロセスで API を作り直す常駐型のツールでは close 忘れが子プロセスの積み上がりになります。snapshot.dispose() はスナップショット側の状態を解放するもので、プロセスは解放しません。

もう1点、updateSnapshot に openProjects を渡さずに呼ぶと、プロジェクトが1つも読み込まれず getProjects() が空配列を返します。tsconfig.json のパスを明示的に開く手順が、6系にはなかった追加の一手です。engines.node は >=16.20.0 が指定されています。

移行を判断する前に確認すべきエコシステムの対応状況

ラッパーライブラリとして広く使われている ts-morph は、最新の 28.0.0(JST 2026年4月13日公開)が @ts-morph/common 0.29.0 に依存し、そのパッケージが TypeScript 6.0.2 を同梱しています。ESLint 系の型情報付き lint を支える @typescript-eslint/typescript-estree は、8.65.0(JST 2026年7月21日公開)の peerDependencies が typescript を「>=4.8.4 <6.1.0」と定めており、7系は対象外です。

依存ライブラリを1つでも挟むなら、7系はまだ選べません。新規に書く場合も既存を保守する場合も、当面は 6系の ts.* API で実装するのが妥当です。7系の unstable API は、tsgo 本体の速度が必要で、かつ依存ライブラリを持たない自作ツールに限って検討する段階にあります。TypeScript 7 とネイティブコンパイラ tsgo そのものの位置づけはGo製ネイティブコンパイラtsgoの解説記事に、5系から6系への移行手順はTypeScript 6.0の新機能と移行手順のまとめにまとめています。

Compiler APIを直接使うべきでない場面

ts-morphで十分なケース

Compiler API を直接叩く判断は、多くのプロジェクトで過剰です。クラスにメソッドを追加する、import 文を並べ替える、型定義ファイルからプロパティ一覧を取り出すといった作業は、ts-morph なら AST ノードを組み立てずに書けます。ノードの追加・削除・整形をメソッド呼び出しで表現でき、変更後のフォーマット維持も担ってくれます。TypeScript 本体を内部に同梱しているため、バージョン整合の管理も1つ減ります。

Compiler API を直接使う価値があるのは、ts-morph が抽象化しきれない領域に踏み込むときです。TypeChecker の細かな挙動に依存する型解析、コンパイル前に AST を差し替えるカスタムトランスフォーマ、tsc の出力そのものへの介入がこれにあたります。要件がノードの読み書きに収まるなら、ts-morph を選んでください。

lintルールの実装にCompiler APIを選ぶ失敗

コーディング規約のチェックを自作したい、という動機で Compiler API に手を出すのは典型的な遠回りです。ファイル探索・キャッシュ・エラー報告のフォーマット・エディタ統合・自動修正まで、すべて自前で実装することになります。同じことは typescript-eslint のカスタムルールとして書けばルール本体だけで済み、型情報が必要なルールでも parserServices 経由で TypeChecker に到達できます。

速度が問題なら、解析基盤ごと替える選択もあります。Rust 製のツールチェーンであるOxcとOxlintの構成は、TypeScript の AST 解析を JavaScript のランタイム外で処理する設計です。ESLint との違いや移行の勘所はOxlintの使い方とESLint・Biomeとの比較で扱っています。自作 lint に Compiler API を直接使うのは、既存のルール基盤では表現できない検査が明確にある場合だけに限ってください。

よくある質問

TypeScript Compiler APIの公式ドキュメントはどこにありますか

一次資料は microsoft/TypeScript-wiki リポジトリの Using-the-Compiler-API.md で、GitHub 上の microsoft/TypeScript の Wiki タブからも同じページを参照できます。最小のコンパイラ、型チェッカを使った情報取得、インクリメンタルなトランスパイルなどのサンプルが掲載されています。コンパイラ内部の構造は同リポジトリの Architectural-Overview.md と Compiler-Internals.md が扱っています。API の正確な形は、インストールした typescript パッケージの型定義ファイル(6系なら lib/typescript.d.ts)を直接読むのが確実です。

ts.createProgramが「見つからない」というエラーが出るのはなぜですか

インストールされている typescript が 7.x である可能性が高いです。7.0.2 のパッケージにはルート実装として lib/version.cjs しか公開されておらず、従来の ts.* API は含まれていません。npm install typescript とだけ実行すると latest である 7.x が入るため、既存の解説記事どおりに書いたコードが動かなくなります。npm install [email protected] のようにバージョンを明示して入れ直してください。7系の API を使う場合は、typescript/unstable/sync など unstable 名前空間からのインポートに書き換える必要があります。

TypeScriptコンパイラのソースコードはどこで読めますか

系列によってリポジトリが分かれます。5系と6系の TypeScript 本体は microsoft/TypeScript リポジトリの src 配下にあり、パーサ(src/compiler/parser.ts)、バインダ、型チェッカ(src/compiler/checker.ts)という順に処理が流れます。7系のネイティブ実装は microsoft/typescript-go リポジトリで開発されており、Go で書かれています。

Compiler APIを使うのにTypeScriptで書く必要はありますか

JavaScript からでも呼び出せます。API は typescript パッケージが公開する通常の JavaScript モジュールなので、Node.js の JavaScript ファイルから require や import で読み込んで使えます。ただし SyntaxKind の値やノードの構造は型定義に依存する部分が大きく、型注釈がないと isFunctionDeclaration などで絞り込んだ後のプロパティ補完が効きません。ノードの種類が数十種類に及ぶ実装では、TypeScript で書いた方が結果的に速く仕上がります。

解析対象のファイルが増えてProgramの生成が遅いときはどうすればよいですか

createProgram は依存関係をたどって型定義ファイルまで読み込むため、対象が増えるほど初期化に時間がかかります。同じプロセスで繰り返し解析するなら、毎回 createProgram を呼ばず、ts.createIncrementalProgram や oldProgram を渡した再生成で差分だけを処理してください。それでも足りない規模では、7系の tsgo や Rust 製の Oxc など、ネイティブ実装の解析基盤への切り替えが選択肢になります。

関連記事

資料請求

RELATED POSTS 関連記事