こんにちは。N 予備校 Webフロントエンド開発チームの中村です。
TypeScriptを使用しているプロジェクトでコンパイラの設定を変更したら既存のソースコードがコンパイルに通らなくなった……という経験はないでしょうか。
先日あるリポジトリでnoUncheckedIndexedAccess
というコンパイラオプション(TypeScript4.1以降で使用可能)を有効化した1ところ、既存ソースコードの200箇所以上がコンパイルエラーになりました。これを全て手作業で直すのは大変ですし、その間にも直さないといけないコードは増えていくかもしれません。
そこでTypeScriptのCompiler APIを使用し、コンパイラから得られるコンパイル時のエラー情報を利用して@ts-expect-error
2を挿入するスクリプトを作成しました。その過程と結果を書きましたので、次のような方々の参考になれば幸いです。
- TypeScriptのコンパイラオプションを有効にしたいけれど既存のソースコードを先に修正するまで直せなくて困っている
- 手作業で既存のコードに
@ts-ignore
や@ts-expect-error
を頑張って追加している
Compiler API とは、簡単に言えばtsc
コマンドが内部的に行なっている静的解析やトランスパイル等の処理をプログラム中から呼び出すためのインターフェースです。詳細についてはTypeScriptリポジトリのwikiをご覧ください。
実装
実行環境は以下の通りです。
typescript: 4.5.5 ts-node: 10.4.0 node: 14.19.0
まず、実行前のエラーがどれぐらいあるのか確認してみます。
npx tsc --no-emit
プロジェクトの設定を取得する
Compiler APIをプログラム中から利用してコンパイルエラーのある箇所を出力してみます。今回はsample.tsという名前でファイルを作成しています。
tsconfigからプロジェクトの設定を取得します。3
import * as ts from 'typescript'; // tsconfig.jsonからプロジェクトの設定を読み取る const configPath = ts.findConfigFile( process.cwd(), ts.sys.fileExists, 'tsconfig.json', ); if (!configPath) { throw new Error('Could not find a valid "tsconfig.json".'); } const { config } = ts.readConfigFile(configPath, ts.sys.readFile); const { options, fileNames } = ts.parseJsonConfigFileContent(config, ts.sys, process.cwd()); console.log('コンパイラオプション:', options); console.log('コンパイル対象のファイル: ', fileNames);
スクリプトの実行にはts-node
を使います。オプションに-O '{"module": "commonjs"}'
を指定してください。また、プロジェクトルート(tsconfigが置いてあるディレクトリ)で実行するようにしてください。
これを実行した結果、以下のように出力されました。
$ npx ts-node -O '{"module": "commonjs"}' sample.ts コンパイラオプション: { [省略] } コンパイル対象のファイル: [ [省略] ]
コンパイルの実行
次に、tsc --no-emit
に相当する処理を実装します。コンパイルエラーを取得して表示してみます。
// コンパイル const program = ts.createProgram({ options, rootNames: fileNames }); const emitResult = program.emit(undefined, () => {/* ファイルの出力はしない */}); const allDiagnostics = ts.getPreEmitDiagnostics(program).concat(emitResult.diagnostics); allDiagnostics.forEach((diagnostic) => { const { file, start } = diagnostic; if (!file || !start) { return; }; const { line, character } = file.getLineAndCharacterOfPosition(start); console.log(`${file.fileName}:${line}:${character}`); });
これを実行すると、最初のtsc
コマンド実行時に得られたものと対応する ファイル名:行番号:文字番号 が標準出力に出力されていることが確認できると思います。
コメントの挿入処理
それでは、diagnostic
を元に@ts-expect-error
を挿入する処理を実装します。
fs
モジュールを使うためimport宣言を追加します。
import * as fs from 'fs';
次いで、allDiagnosstcs.forEach
に渡しているコールバックを以下のように修正します。ファイル単位で処理した方が都合が良いのでts.Diagnostic.file
をキーにしたMapオブジェクト(filePositionMap
)を作成しています。
const filePositionMap = new Map(); allDiagnostics.forEach((diagnostic) => { const { file, start } = diagnostic; if (!file || !start) { return; }; }); // エラー行の前に"@ts-expect-error"を挿入して元のファイルを上書きする filePositionsMap.forEach((positions, file) => { const newFileText = file.text .split('\n') .map((lineText, index) => { const isError = positions.some((pos) => file.getLineAndCharacterOfPosition(pos).line === index); if (!isError) { return lineText; } const indent = lineText.match('^s*')?.[0] ?? ''; return `${indent}// @ts-expect-error\n${lineText}`; }) .join('\n'); fs.writeFileSync(file.fileName, newFileText); console.log('update', file.fileName); });
ここでコマンド実行してみたところ、数件エラーが残ってしまいました。エラーになっている箇所を見てみるといずれもTSX(JSX)の部分でした。その原因はTSXのタグの中ではTypeScript(JavaScript)のコメントアウトが使えないので、
// @ts-expect-error
としているところを
{/* @ts-expect-error */}
のようにする必要があることが考慮から漏れていました。
コメントを挿入する行の情報を取得する
diagnostic
の情報だけではどちらの形式のコメントを挿入すれば良いのかわかりません。そこで、どちらの形式のコメントを挿入するべきか判定するため次の関数を実装することにしました。4
// 指定の位置のASTノードがJSXTextか判定する function isJsxTextAtPosition(node: ts.Node, position: ts.Node['pos']): boolean { const _isJsxTextAtPosition = (n: ts.Node): boolean => n.pos === position && ts.isJsxText(n) ? true : n.forEachChild(_isJsxTextAtPosition) ?? false; return _isJsxTextAtPosition(node); }
isJsxTextAtPosition
関数はTypeScriptのAST(abstract syntax tree, 抽象構文木)を再起的に探索して指定の位置にあるノードの種類(ts.SyntaxKind)がJSXText
であるか調べています。
エラーが起きている行の最初の文字の1つ前がJSXText
であれば、コメントを挿入するべき位置がTSXの文脈であるということになります。
最終的なコード
最終的なコード全体は以下の通りです。(不要なconsole.logは消しています)
import * as fs from 'fs'; import * as ts from 'typescript'; // tsconfigからプロジェクトの設定を読み取る const configPath = ts.findConfigFile(process.cwd(), ts.sys.fileExists, 'tsconfig.json'); if (!configPath) { throw new Error('Could not find a valid "tsconfig.json".'); } const { config } = ts.readConfigFile(configPath, ts.sys.readFile); const { options, fileNames } = ts.parseJsonConfigFileContent(config, ts.sys, process.cwd()); // コンパイル const program = ts.createProgram({ options, rootNames: fileNames }); const emitResult = program.emit(undefined, () => { /* ファイルの出力はしない */ }); const allDiagnostics = ts.getPreEmitDiagnostics(program).concat(emitResult.diagnostics); const filePositionsMap: Map<ts.SourceFile, number[]> = new Map(); allDiagnostics.forEach((diagnostic) => { const { file, start } = diagnostic; if (!file || !start) { return; } const positions = filePositionsMap.get(file); if (!positions) { filePositionsMap.set(file, [start]); } else { positions.push(start); } }); // エラー行の前に"@ts-expect-error"を挿入して元のファイルを上書きする filePositionsMap.forEach((positions, file) => { const newFileText = file.text .split('\n') .map((lineText, index) => { const isError = positions.some((pos) => file.getLineAndCharacterOfPosition(pos).line === index); if (!isError) { return lineText; } const indent = lineText.match('^s*')?.[0] ?? ''; return isJsxTextAtPosition(file, file.getPositionOfLineAndCharacter(index, -1)) ? `${indent}{/* @ts-expect-error */}\n${lineText}` : `${indent}// @ts-expect-error\n${lineText}`; }) .join('\n'); fs.writeFileSync(file.fileName, newFileText); console.log('update', file.fileName); }); // 指定の位置のASTノードがJSXTextか判定する function isJsxTextAtPosition(node: ts.Node, position: ts.Node['pos']): boolean { const _isJsxTextAtPosition = (n: ts.Node): boolean => n.pos === position && ts.isJsxText(n) ? true : n.forEachChild(_isJsxTextAtPosition) ?? false; return _isJsxTextAtPosition(node); }
これを実行後、最初と同様にtsc --no-emit
を実行したところコンパイルエラーが0件になっていることが確認できました。
簡単のために省略しましたが、コマンドライン引数でコメントの内容やTypeScriptのエラーコードなどを受け取れるようにすると便利だと思います。
おわりに
TypeScriptには様々なコンパイラオプションがあり、より厳格な設定にすることでより大きなTypeScriptコンパイラの静的型付けによる恩恵を受けることができます。しかし、既存のプロジェクトにより厳格な設定を適用するときに、エラーになるコードがある場合まずそちらを修正しないとコンパイルが通りません。数箇所であれば直接修正してしまえば良いのですが、プロジェクトの規模によっては手作業で修正するのが大変な場合があります。
@ts-expect-error
を使って既存のコードは一旦コンパイル時にエラーにならないようにし、新規で追加するコードにのみ新しいコンパイラオプションを適用できれば、既存のプロジェクトにもより厳格な設定を導入しやすいのではないでしょうか。
一応デメリットとして、対象の行に複数のエラーが起きた時にも抑制してしまうという点は注意してください。エラーを抑制して設定を変えた後でも@ts-expect-error
を消せるように既存のソースコードを修正していった方が良いでしょう。
この記事はTypeScriptについてですが、ESLintでfixableでないルールを既存プロジェクトに導入したい時なども似たようなアプローチが検討できそうです。5
今回Compiler APIに触れたのが初めてだったので、もっと良い方法(実装)はあると思います。この辺りは個人的にも関心があるので今後も勉強していきたいです。
参考文献など
- Using-the-Compiler-API - microsoft/TypeScriptリポジトリ内のWiki
- microsoft/TypeScript-Compiler-NotesにもTypeScriptコンパイラについての情報が纏まっています
- ts-expect-error を付与しながら .js を .ts に一括で書き換える - .jsを.tsに書き換える際、Diagnosticsを元に
@ts-expect-error
の挿入を行われています。 - 既存プロジェクトに対し、TypeScript設定の厳格化を行う - この記事と同様の課題に対し、テキストベースで
@ts-expect-error
の挿入を行われています。
We are hiring!
株式会社ドワンゴの教育事業では、一緒に未来の当たり前の教育をつくるメンバーを募集しています。 カジュアル面談も行っています。 お気軽にご連絡ください!
開発チームの取り組み、教育事業の今後については、他の記事や採用資料をご覧ください。
-
何故
noUncheckedIndexedAccess
を有効化したのかについては今回の趣旨から外れるため割愛します。↩ -
TypeScriptには特定行のコンパイルエラーを抑制する
@ts-ignore
という特殊コメントがありますが、今回はTypeScript3.9で導入された@ts-expect-error
を使用します。@ts-expect-error
は次の行のコンパイルエラーを抑制するという点は@ts-ignore
と同じなのですが、次の行がエラーにならない場合にエラーになってくれます。つまり、元々エラーを無視したかった行で@ts-ignore
が必要なくなった後でも@ts-ignore
が(消し忘れて)残ってしまうということを避けることができます。今回のようなケースでは、基本的に@ts-ignore
よりも@ts-expect-error
を優先的に用いた方が良さそうです。↩ -
tsconfig.json
を直接インポートする場合、tsconfigはJSONC(JSON with Comment)形式なのでコメントが含まれると実行時にJSONのパースに失敗しエラーになります。↩ -
引数のASTノードに対応する位置にコメントを挿入する
ts.addSyntheticLeadingComment
という関数があるので紹介しておきます。その場合ts.createPrinter
でPrinterを作成してから文字列として出力するといった感じになると思います。↩ -
eslint-interactiveやsuppress-eslint-errorsといったツールを使えば
eslint-disable-next-line
を自動で挿入することができます。↩