TypeScript Compiler API を使って ts-expect-error を一括挿入する

こんにちは。N 予備校 Webフロントエンド開発チームの中村です。

TypeScriptを使用しているプロジェクトでコンパイラの設定を変更したら既存のソースコードがコンパイルに通らなくなった……という経験はないでしょうか。

先日あるリポジトリでnoUncheckedIndexedAccessというコンパイラオプション(TypeScript4.1以降で使用可能)を有効化した1ところ、既存ソースコードの200箇所以上がコンパイルエラーになりました。これを全て手作業で直すのは大変ですし、その間にも直さないといけないコードは増えていくかもしれません。

そこでTypeScriptのCompiler APIを使用し、コンパイラから得られるコンパイル時のエラー情報を利用して@ts-expect-error2を挿入するスクリプトを作成しました。その過程と結果を書きましたので、次のような方々の参考になれば幸いです。

  • 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に触れたのが初めてだったので、もっと良い方法(実装)はあると思います。この辺りは個人的にも関心があるので今後も勉強していきたいです。

参考文献など

We are hiring!

株式会社ドワンゴの教育事業では、一緒に未来の当たり前の教育をつくるメンバーを募集しています。 カジュアル面談も行っています。 お気軽にご連絡ください!

カジュアル面談応募フォームはこちら

www.nnn.ed.nico

開発チームの取り組み、教育事業の今後については、他の記事や採用資料をご覧ください。

speakerdeck.com


  1. 何故noUncheckedIndexedAccessを有効化したのかについては今回の趣旨から外れるため割愛します。

  2. TypeScriptには特定行のコンパイルエラーを抑制する@ts-ignoreという特殊コメントがありますが、今回はTypeScript3.9で導入された@ts-expect-errorを使用します。@ts-expect-errorは次の行のコンパイルエラーを抑制するという点は@ts-ignoreと同じなのですが、次の行がエラーにならない場合にエラーになってくれます。つまり、元々エラーを無視したかった行で@ts-ignoreが必要なくなった後でも@ts-ignoreが(消し忘れて)残ってしまうということを避けることができます。今回のようなケースでは、基本的に@ts-ignoreよりも@ts-expect-errorを優先的に用いた方が良さそうです。

  3. tsconfig.jsonを直接インポートする場合、tsconfigはJSONC(JSON with Comment)形式なのでコメントが含まれると実行時にJSONのパースに失敗しエラーになります。

  4. 引数のASTノードに対応する位置にコメントを挿入するts.addSyntheticLeadingCommentという関数があるので紹介しておきます。その場合ts.createPrinterでPrinterを作成してから文字列として出力するといった感じになると思います。

  5. eslint-interactivesuppress-eslint-errorsといったツールを使えばeslint-disable-next-lineを自動で挿入することができます。