この記事はTypeScript Advent Calendar 2023の20日目の記事です。
はじめに
皆さんはTypeScript Compiler APIを使用したことはありますか?
TypeScript Compiler APIの情報は少なく、初めて触れる方にとっては敷居が高く感じるかもしれません。
様々なツールを作成できますが、ソースコードの解析はTypeScript Compiler APIを学ぶ入門としてちょうどよく、その他のツールを作成する際にもソースコードの解析に関連する技術は非常に役立ちます。
この記事ではTypeScript Compiler APIを用いて、ソースコードを解析するために必要な前提知識と簡単なサンプルを紹介します。
動作環境
- TypeScript: 5.3.3
TypeScript Compiler APIとは
TypeScript Compiler APIは、TypeScriptのコンパイラをプログラムから利用できるAPIです。提供されるAPIを用いて、TypeScriptのソースコードの解析、変換、カスタム型チェッカーの実装、ソースコードを自動生成など、TypeScriptプロジェクトで役立つツールを作成することができます。
しかし、TypeScript Compiler APIは安定版ではありません。バージョンアップ時に破壊的変更が発生する可能性があることに注意してください。
抽象構文木(Abstract Syntax Tree、AST)
TypeScript Compiler APIを使用してソースコードを解析し、変換するためには、抽象構文木(AST)に関する基本的な知識が必要です。
ASTはソースコードの構造を表現するために使われるデータ構造です。ノードと呼ばれる要素で構成され、ツリー構造になっています。
例えば、以下のコードで考えてみましょう。
function add(a, b) {
return a + b
}
このコードは、関数定義(function add(a, b))とその中の演算(return a + b)から成り立っています。ASTはこのコードを以下のように表現します。
Program
└─ FunctionDeclaration (name: "add")
├─ Identifier (name: "a")
├─ Identifier (name: "b")
└─ BlockStatement
└─ ReturnStatement
└─ BinaryExpression (operator: "+")
├─ Identifier (name: "a")
└─ Identifier (name: "b")
このASTツリーは、プログラムの構造を階層的に表現し、親ノードと子ノードの関係を示しています。JavaScriptでは、ESTreeがASTの共通仕様として一般的に使用されていますがWeb標準ではありません。そのため、パーサー毎に独自の拡張が追加されています。
各パーサーのASTは以下のツールで確認できます。
それでは、パーサー毎に作成されるASTの違いを見てみましょう。
以下のソースコードをtypescript-eslint/parserとTypeScript Compilier APIでASTに変換してみます。
※ASTの空のプロパティは省略しています。
function isEven(num: number) {
return num % 2 === 0
}
typescript-eslint/parserはESTree互換(TypeScriptの部分は除く)ですが、TypeScript Compilier APIのASTは、ESTreeとは異なるASTになっているのが分かると思います。
ソースコードからType Assertionを使用している箇所を特定してみる
TypeScript Compilier APIを用いた簡単なサンプルとして、ソースコードからType Assertionを使用している箇所を特定してみます。
ソースコードは以下の通りです。
import * as ts from "typescript"
function visitAll(node: ts.Node, callback: (child: ts.Node) => void) {
callback(node)
node.forEachChild(child => visitAll(child, callback))
}
function isTypeAssertion(node: ts.Node) {
return ts.isTypeAssertionExpression(node) || ts.isAsExpression(node)
}
function printTypeAssertionLog(sourceFile: ts.SourceFile, node: ts.Node, filename: string) {
const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
console.log(`Type Assertion detected in file ${filename} at line ${line + 1}, column ${character + 1}`)
}
function detectTypeAssertions(filepath: string) {
const program = ts.createProgram([filepath], {})
const sourceFile = program.getSourceFile(filepath)
if (!sourceFile) {
console.error("Source file not found.")
process.exit(1)
}
visitAll(sourceFile, node => {
if(isTypeAssertion(node)) {
printTypeAssertionLog(sourceFile, node, filepath)
}
})
}
detectTypeAssertionsは以下のソースコードファイルを入力として受け取ると、次のような出力を生成します。
// example.ts
const hoge = 'hoge' as any
const bar = 'bar'
const piyo = <any>'piyo'
function foo() {
return 'foo' as unknown
}
Type Assertion detected in file ./example.ts at line 3, column 14
Type Assertion detected in file ./example.ts at line 5, column 14
Type Assertion detected in file ./example.ts at line 8, column 10
コード量は少ないですが、それぞれの処理を解説していきます。
まず入力されたファイルパスからProgramとSourceFileを作成します。
// ts.createProgramの第二引数はTypeScriptのコンパイラオプションです。
const program = ts.createProgram([filepath], {})
const sourceFile = program.getSourceFile(filepath)
Programは複数のSourceFileとコンパイラオプションで構成されており、TypeScriptアプリケーション全体を表します。
SourceFileは各ファイルを表しており、ASTのルートノードになります。
次にASTをトラバースするにはforEachChild、もしくはgetChildrenを使用します。
node.forEachChild(child => {
// do something
})
全てのASTをトラバースするためにルートのノードから再起的に処理していきます。
トラバースとノードの判定などをまとめる実装と分離する実装があると思いますが、好きな実装でいいと思います。
function detectTypeAssertions(node: ts.Node) {
// do something
ts.forEachChild(node, child => detectTypeAssertions(child))
}
function visitAll(node: ts.Node, callback: (child: ts.Node) => void) {
callback(node)
node.forEachChild(child => visitAll(child, callback))
}
function detectTypeAssertions(node: ts.Node) {
// do something
}
visitAll(node, detectTypeAssertions)
is*系のメソッド
ノードの特定はis*
系のメソッドを使用します。
APIドキュメントが存在しないため、エディターの型推論から判定したいノードに適したメソッドを使用しましょう。
Type Assertionの2通りの記法を判定する必要があるため、isTypeAssertionExpressionとisAsExpressionを使用します。
// isTypeAssertionは<any>'hoge'を判定
// isAsExpressionは'hoge' as anyを判定
function isTypeAssertion(node: ts.Node) {
return ts.isTypeAssertionExpression(node) || ts.isAsExpression(node)
}
残りは対象のノードの行数とカラム数をログに出力するだけです。
function printTypeAssertionLog(sourceFile: ts.SourceFile, node: ts.Node, filename: string) {
const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
console.log(`Type Assertion detected in file ${filename} at line ${line + 1}, column ${character + 1}`)
}
まとめ
この記事では、TypeScript Compiler APIを用いてソースコードを解析するために必要な前提知識とソースコードからType Assertionを特定するサンプルを紹介しました。
他の活用例はこちらの記事にも書いてあるので、ぜひご覧ください。
また、TypeScript Compiler APIは低レベルなAPIで記述が冗長になりがちです。
TypeScriptのソースコードをより簡潔に解析および操作したい場合には、ts-morphの使用をお勧めします。