2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

TypeScript 7で型チェックを4倍速にしたら、npx tsc がv6を実行していた話

2
Posted at

はじめに

TypeScript 7.0(Go製のネイティブコンパイラ)を導入して、既存プロジェクトの型チェックを高速化しました。結果は tsc --noEmit約91秒 → 約22〜24秒 と、おおよそ4倍speedup。検出されるエラーの件数は前後で変化なしでした。

ただし、公式ブログの手順どおりに入れただけでは足りない落とし穴がありました。npx tsc がTypeScript 6を実行してしまうことがある、しかも非決定的で気づきにくい、という問題です。

この記事では、TypeScript 7導入時にプロジェクト全体で気をつけるべき点と、その対策を書きます。同じ移行をする人が確実に踏むポイントだと思います。

対象読者は、Vite + React + TypeScript構成のプロジェクトでTypeScript 7への移行を検討している人です。


前提:なぜTypeScript 6と7を並べて入れるのか

結論から言うと、プログラマティックAPIを利用しつつ、tscの型チェックは高速化したいからです。この2つを同時に満たすバージョンが存在しないため、両方を並べて入れる必要があります。

TypeScript 7.0はGo製のネイティブコンパイラですが、プログラマティックAPI(他のツールがコードからコンパイラを呼び出すためのJavaScript API)をまだ提供していません。安定版のプログラマティックAPIを公開しているのはTypeScript 6が最新で、7.0でのAPI提供は早くても7.1とされています。

一方で、typescript-eslint、Vite、ts-jestなどは require('typescript') でプログラマティックAPIを直接使っています。単純に typescript を7系に上げると、これらのツールが動かなくなります。

逆に言えば、プログラマティックAPIを必要とするツールを一切使っていないプロジェクトなら、typescript を7系に上げるだけで済みます。この記事の話は不要です。まずは自分のプロジェクトがどちらかを確認してください。

そのため公式は、TypeScript 6と7をnpmエイリアスで並べて入れる方法を案内しています(Announcing TypeScript 7.0)。

{
  "devDependencies": {
    "@typescript/native": "npm:typescript@^7.0.2",
    "typescript": "npm:@typescript/typescript6@^6.0.2"
  }
}
  • typescript → 実体は @typescript/typescript6(v6のAPIを再エクスポートする互換パッケージ)。ESLintなどはこれを見る
  • @typescript/native → 実体はTypeScript 7.0。型チェック専用

なお、@typescript/native-preview というパッケージと tsgo というコマンドも存在しますが、これは7.0より先を試すナイトリー版です。通常の7.0移行で使うものではありません。安定版のバイナリ名は tsc です。ここは混同しやすいので注意してください。


問題:npx tsc がTypeScript 6を実行することがある

公式手順どおりにエイリアスを2つ入れたのに、npx tsc がv6を実行していました。

公式ブログには、@typescript/typescript6 が公開する実行ファイルは tsc6 のみであり、tsc という名前は7.0側に空けてある、と書かれています。両方のエイリアスを入れれば npx tsc はそのまま7.0で動く、とも明記されています。設計意図としては競合しません。

しかし実際には競合しました。

原因:ドキュメント化されていない内部依存

node_modules/typescript/package.json(実体は @typescript/typescript6)を開くと、こうなっています。

{
  "dependencies": {
    "@typescript/old": "npm:typescript@^6"
  }
}

@typescript/typescript6 は薄い互換レイヤーで、v6の本体は @typescript/oldnpm:typescript@^6 のエイリアス)として内部に抱えられています。

そしてこの @typescript/old は、typescript パッケージそのものなので tsctsserver のbinを持ち込みます。結果、node_modules/.bin/tsc を巡ってTypeScript 7と衝突します。

npmは、複数のパッケージが同じbin名を宣言したときにどちらを採用するかを保証していません(npm/cli#3905)。つまり どちらが勝つかは環境とインストール履歴に依存する非決定的な挙動です。

これはmicrosoft/TypeScriptにもIssueとして上がっています(#63634)。また、このパッケージング詳細をドキュメント化してほしいというIssue(#63866)はWon't Fixで閉じられています。公式ブログだけを読んでいると気づけません。

自分の環境では何が起きていたか

私の場合は次のような経緯でした。

  1. @typescript/native-preview を先に導入 → 後で削除
  2. エイリアスを追加して npm install
  3. さらに個別パッケージを npm install --save-dev で追加
  4. この過程で、プラットフォーム別バイナリパッケージが欠落する不整合も発生

こうした断続的なインストールを重ねた結果、npmのbinシンボリックリンク計算が @typescript/old 側の tsc を指す状態になっていた、と考えられます。

厄介なのは、これがエラーとして現れないことです。tsc は正常に動きます。ただ実行されているのがv6なので、「高速化したはずなのに速くならない」あるいは「7.0では落ちるはずのコードが通る」という形でしか気づけません。


解決方法

まず自分の環境を確認する

原因の理解より先に、これを実行してください。

# .bin/tsc がどこを指しているか(これが本体)
ls -l node_modules/.bin/tsc

# 各実行ファイルのバージョン
npx tsc --version        # 7.0.x になっているか?
npx tsc6 --version       # 6.0.x
node ./node_modules/@typescript/native/bin/tsc --version  # 必ず7系

# ESLintなどが見ているAPIのバージョン
node -e "console.log(require('typescript').version)"

判定基準は1行目です。

  • ../@typescript/native/bin/tsc を指している → 意図どおり
  • ../@typescript/old/bin/tsc を指している → アウト

対策1:整合性のある状態で入れ直す

まず試すのはこれです。

rm -rf node_modules
npm ci

package-lock.json から一度で整合性のある状態を構築し直すと、公式が意図したとおりの解決結果に戻ります。私の環境ではこれで直りました。

ただし、npmのbin解決に依存している以上、CIや他のメンバーの環境で再発しない保証はありません。これは根本対策になりません。

対策2:スクリプトでバイナリ名に依存しない(本命)

npx tsc という書き方をやめ、実際のパスを直接指定します

{
  "scripts": {
    "typecheck": "node ./node_modules/@typescript/native/bin/tsc --noEmit"
  }
}

こうしておけば、node_modules/.bin/tsc がどちらを指していても、必ずTypeScript 7が実行されます。npmのbin解決という不確実な要素を経路から外すのがポイントです。

タイプ数は増えますが、「なぜか遅い」「なぜかエラーが出ない」という原因の分かりにくいトラブルを構造的に潰せます。チーム開発なら確実にこちらにすべきです。

最終的な設定

{
  "scripts": {
    "typecheck": "node ./node_modules/@typescript/native/bin/tsc --noEmit"
  },
  "devDependencies": {
    "@typescript/native": "npm:typescript@^7.0.2",
    "typescript": "npm:@typescript/typescript6@^6.0.2",
    "eslint": "^9.39.5",
    "typescript-eslint": "^8.69.0",
    "vite": "^5.4.0",
    "@vitejs/plugin-react": "^4.3.0"
  }
}

eslint / typescript-eslint / vite を併記しているのは、これらが「TypeScript 6のAPIを必要とする側」だからです。エイリアス分割が必要な理由がここにあります。

ESLintへの影響がないことの確認

分割によってESLintが壊れていないかは、実測で確認しました。

  • require('typescript').version6.0.3 のまま
  • npm run lint の警告件数は移行前後で477件のまま(差分0)

なお、今回のプロジェクトのESLint設定は型情報を使わない軽量モード(recommendedTypeChecked ではなく recommendedparserOptions.project 未設定)です。型情報を使う設定の場合、v6とv7で型推論の結果が食い違う可能性があるので、同じ結果になるとは限りません。


おわりに

TypeScript 7への移行で押さえるべき点をまとめます。

  • 速度は本物。91秒 → 22〜24秒、エラー件数の差分なし
  • 7.0にはまだプログラマティックAPIがないので、ESLintやViteのためにv6との並行インストールが必要(API安定版は7.1以降の予定)
  • npx tsc は信用しない@typescript/old のbin衝突でv6が実行されることがあり、しかも非決定的。スクリプトでは実パスを直接書く

前提として、先にTypeScript 6.0へ上げて非推奨警告を潰しておくことをおすすめします。7.0は6.0の新しいデフォルトを引き継ぎ、6.0で非推奨だったものを7.0ではハードエラーにします。5系から直接飛ぶと、速度以前に大量のエラーと向き合うことになります。6.0を経由するのは小さく可逆なステップで、7.0が何を拒否するかを事前に教えてくれます。

「エラーが出ないから動いている」と思い込みやすいポイントが多い移行でした。バージョンを実測で確認する習慣が、いちばんの対策かもしれません。


参考


JISOUのメンバー募集中!

プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか?
興味のある方は、ぜひホームページをのぞいてみてください!
▼▼▼
https://projisou.jp

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?