# tsc v7対応記録

複数パッケージ構成のリポジトリをTypeScript 7へ移行した。`typescript`のバージョンを上げるだけでは終わらず、依存の指定方法、`tsconfig.json`、実装コードのそれぞれに変更が必要になった。

## TypeScript 7で変わった点

[TypeScript 7.0の公式発表](https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/)によれば、TypeScript 7はコンパイラをGoで再実装したネイティブ移植版であり、移行に影響したのは次の点である。

- `tsc`がネイティブ実装に置き換わった
- 7.0の`typescript`パッケージはCompiler APIを提供しない
- 6系で非推奨になった設定の一部が削除された
- 一部の設定は既定値が変わった

### ネイティブ実装とCompiler API

ここでいうネイティブ実装とは、JavaScriptランタイム上で解釈されるのではなく、OSとアーキテクチャごとにビルドされた実行ファイルとして動く形を指す。`tsc`本体がこの形になったため、従来のように`import ts from "typescript"`と読み込んで使う安定したAPIは提供されなくなった。

そのため移行の設計を最も左右するのがCompiler APIの不在である。Compiler APIとは、TypeScript 6系までのJavaScript実装が提供してきたAPIである。構文解析、`tsconfig.json`の解釈、診断メッセージの整形などをプログラムから呼び出せる。TypeScriptをJavaScriptから読み込んで処理するツールは、このAPIに依存している。逆に`tsc`をコマンドとして起動するだけのツールは影響を受けない。

ただしAPIが無いのは恒久的な仕様ではなく、公式発表は7.1で新しいAPIを提供する予定だと述べている。したがって7.0の時点では、Compiler APIを使うツールを6系に残したまま、型検査だけを7系へ移す。

## 7系コンパイラと6系互換APIを共存させる

### 7系だけを入れると周辺ツールが動かない

`typescript`を7系へ置き換えると`pnpm exec tsc`は7系になるが、その一方でCompiler APIを読み込むツールは動かなくなる。公式発表が名指ししているのはtypescript-eslintで、型情報を使う規則のために`typescript`をpeer依存として読み込むためである。ts-jestやTypeDocのように`typescript`をpeer依存として読み込むツールも、APIが無くなれば同じように動かなくなる。

この状況に対して、公式発表は[6.0と併存させる構成](https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/#running-side-by-side-with-typescript-60)を案内している。7系コンパイラと6系互換APIを、別々の依存名で入れる。

| 依存名 | 割り当てるパッケージ | 得られるもの |
| --- | --- | --- |
| `@typescript/native` | 7系の`typescript` | 型検査に使う`tsc` |
| `typescript` | `@typescript/typescript6` | 互換APIと`tsc6`というCLI |

依存名に別のパッケージを割り当てる指定はnpmエイリアスと呼ばれ、`package.json`に`"依存名": "npm:パッケージ名@バージョン"`と書くことで指定できる。

### 依存の指定

公式が案内するのは、両方をnpmエイリアスとして`devDependencies`へ書く形である。

```json
{
  "devDependencies": {
    "@typescript/native": "npm:typescript@7",
    "typescript": "npm:@typescript/typescript6@6"
  }
}
```

直接依存に持つツールについては、この形でもツール側のpeer依存が互換パッケージで解決される。間接依存まで含めて解決先を固定しておきたい場合は、差し替えを`pnpm.overrides`側へ置く方法もある。

```json
{
  "devDependencies": {
    "@typescript/native": "npm:typescript@7",
    "typescript": "6"
  },
  "pnpm": {
    "overrides": {
      "typescript": "npm:@typescript/typescript6@6",
      "typedoc": "0.28.20"
    }
  }
}
```

`overrides`は依存ツリー全体の解決先を差し替える指定だが、インストール単位のルートにあたる`package.json`だけが持つ。複数のパッケージをまとめて管理する構成では、子側のパッケージに書いても反映されないため、ルート側へ置く必要がある。

なおこの差し替えの書き方はパッケージマネージャーごとに違う。pnpmは`pnpm.overrides`、npmはトップレベルの`overrides`、yarnは`resolutions`を使う。npmにはさらに制約があり、直接依存に持つパッケージへ`overrides`をかける場合、依存側の指定と同じでなければエラーになる。一致させられないときは`"typescript": "$typescript"`のように依存側を参照する書き方が要る。一方でエイリアス自体の書き方（`npm:パッケージ名@バージョン`）と、互換パッケージがTypeScript本体を内包する構造は、パッケージマネージャーによらず共通である。

### 周辺ツールの版を上げる必要がある

周辺ツールは、受け入れるTypeScriptの範囲をpeer依存として宣言している。6系互換パッケージはバージョン番号が6系であるため、この範囲判定においても6系として扱われ、上限が6未満のままのツールは条件を満たせずpeer不一致として報告される。これがインストールの失敗になるかは設定によって変わる。`strictPeerDependencies`は、peer依存の欠落や範囲不一致があるときコマンドを失敗させる設定である。pnpmでは既定で無効なので、そのままなら警告に留まる。有効にしている場合や厳格に扱うパッケージマネージャーでは、インストール自体が止まる。どちらにせよツールが要求する範囲を外れた状態なので、更新が要る。

| ツール | 移行前のpeer範囲 | 移行後のpeer範囲 |
| --- | --- | --- |
| ts-jest | `>=4.3 <6` | `>=4.3 <7` |
| `@typescript-eslint/parser` | `>=4.8.4 <6.0.0` | `>=4.8.4 <6.1.0` |

いずれも上限の引き上げが入った版へ更新すれば解決する。TypeDocのように上限がもともと6系を含んでいるツールでは範囲の問題は起きないが、間接依存として引き込まれる版が揺れないよう`overrides`で固定しておく。移行を始める前にpeer範囲を確認しておくと、更新が必要なツールを先に洗い出せるため、作業中に解決エラーで止まらずに済む。

```bash
npm view ts-jest peerDependencies.typescript
```

### 互換パッケージのバージョンは読み込まれるAPIのバージョンと一致しない

6系互換パッケージは、TypeScript 6本体を別の依存名で内包する構造になっており、ロックファイルには次のように記録される。

```yaml
'@typescript/typescript6@<互換パッケージの版>':
  dependencies:
    # 互換パッケージが内包するTypeScript本体。版は互換パッケージ自体とは別に動く
    '@typescript/old': typescript@<6系本体の版>
```

つまり`package.json`に書くのは互換パッケージ自体の版であり、周辺ツールが読み込むCompiler APIの版は内包側で決まる。内包側の指定は範囲指定なので、両者が一致する保証はない。確定した値はロックファイル側にあり、`package.json`の指定を見ただけでは分からない。

また7系側はネイティブ実装であるため、OSとアーキテクチャごとのバイナリが省略可能な依存として並ぶ。

```yaml
typescript@<7系の版>:
  optionalDependencies:
    '@typescript/typescript-darwin-arm64': <同じ版>
    '@typescript/typescript-linux-arm64': <同じ版>
    '@typescript/typescript-linux-x64': <同じ版>
```

### どのパッケージに何を入れるか

7系`tsc`は型検査するパッケージすべてで必要になるが、6系互換APIが必要なのはCompiler APIを読み込むツールを持つパッケージだけである。例えば型宣言の出力だけを行うパッケージや、lintプラグインのように`tsc`で型検査するだけのパッケージがある。どちらもTypeScriptを内部で使うツールを持たないため、互換APIは要らない。

候補は`devDependencies`から拾える。ただし依存名だけでは、間接的に読み込んでいる場合や`tsc`をコマンドとして呼ぶだけの場合と区別できない。ロックファイルで`typescript`をpeerに要求しているものを確認し、起動して確かめるのが確実である。両方を全パッケージへ入れると必要のない場所にも互換パッケージが残り、逆に必要な場所から外せばそれらのツールが動かないため、パッケージごとの判別は避けられない。

### `tsc`の実行を`pnpm exec`へ統一する

型検査のコマンドは`npx tsc -b`より`pnpm exec tsc -b`のほうが確実である。`npx`はローカルに該当コマンドがなければレジストリから取得して実行するため、インストール済みの依存と違うコンパイラを動かす余地が残る。`pnpm exec`は`node_modules/.bin`を優先して解決し、足りないパッケージを自動で取得することはない。

## tsconfig.jsonの削除された設定と変わった既定値

### 削除された設定

移行前の`tsconfig.json`をそのまま渡すと、削除済みオプションと非相対パスがまとめて報告される。

```text
error TS5108: Option 'target=ES5' has been removed.
error TS5108: Option 'moduleResolution=node10' has been removed.
error TS5102: Option 'baseUrl' has been removed.
error TS5102: Option 'downlevelIteration' has been removed.
error TS5090: Non-relative paths are not allowed. Did you forget a leading './'?
```

いずれも6.0で非推奨になっていたものが、7.0でエラーに変わった。公式発表は個々の削除理由までは述べていないが、それぞれが何のための設定だったかを踏まえると対応の見当はつく。

| 設定 | 何のための設定だったか | 対応 |
| --- | --- | --- |
| `target: "es5"` | ES5しか解釈できない実行環境に合わせて出力する | 実行環境に合わせて`ES2020`や`ES2022`へ変更 |
| `downlevelIteration: true` | 実行環境に`Symbol.iterator`がある前提で、ES5出力の`for...of`などを仕様に近い形へ変換する | 削除。`target`を上げれば変換自体が要らなくなる |
| `moduleResolution: "node"` | CommonJS時代のNode.jsの解決規則をそのまま使う | Node.jsが直接実行する対象は`Node16`か`NodeNext`、バンドラを通す対象は`Bundler` |
| `baseUrl` | 非相対importと`paths`を解決する基準ディレクトリを決める | 削除 |
| `paths`の非相対指定 | `baseUrl`からの相対で解決先を書く | 先頭に`./`を付けてプロジェクトルートからの相対にする |

`target`と`downlevelIteration`は連動しており、`target`を上げれば後者は役目を終える。`moduleResolution`と`baseUrl`は解決規則そのものの変更であるため、値を差し替えるだけでなく、解決先が変わっていないかまで確認する必要がある。

このうち`module`と`moduleResolution`は、機械的に同じ値へ揃えられるものではない。`module`は出力するJavaScriptのモジュール形式を、`moduleResolution`は`import`の指定子をどのファイルへ解決するかを決める。どちらもコードを読む側の規則に合わせる。Node.jsが直接読むESLintのローカルルールは`Node16`、Next.jsやViteを通すものは`ESNext`と`Bundler`になる。

Node.jsは`import`と`export`を使うES Modules（ESM）と、`require`と`module.exports`を使うCommonJSの両方を扱う。どちらであるかは、拡張子と最も近い`package.json`の`type`からファイルごとに判定される。CommonJSと判定されたファイルに`import`文を出力すればパースで失敗し、ESMと判定されたファイルに`require`を出力すれば評価で失敗する。`Node16`と`NodeNext`は同じ判定をコンパイラ側でも行うため、食い違いが型検査で出る。

指定子の書き方も分かれる。Node.jsのESMは拡張子を省略できないが、バンドラは`exports`を解釈しつつ拡張子のない指定子も解決する。`Bundler`はこのバンドラ側の規則に合わせた値であり、代わりにNode.js向けの値を置くと不要な拡張子を要求される。

`module`と`moduleResolution`は独立ではない。片方だけを`Node16`や`NodeNext`にするとエラーになり、`Bundler`は`module`に`ESNext`を要求する。`Node16`と`NodeNext`の違いはNode.jsの仕様変更に追随するかどうかで、解決アルゴリズムは同じである。

さらに複数パッケージ間で型情報を参照する構成では、増分ビルド情報の出力先を`tsBuildInfoFile`で固定する。出力ディレクトリの構造に依存するパッケージでは`rootDir`の明示も要る。既定値に頼っていた箇所は既定値が変われば壊れるため、移行のタイミングで明示しておく。

### baseUrlの削除はpathsの値を壊す

`baseUrl`は`paths`の値を解決する基準ディレクトリを指定する設定であり、指定があると`paths`の値はそこからの相対として解決される。したがって`baseUrl`の行だけを削除すると、`paths`の値はそのままでも解決先が変わってしまう。

```json
{
  "compilerOptions": {
    // 移行前: pathsの値は baseUrl(./src) からの相対
    "baseUrl": "./src",
    "paths": {
      "@lib/*": ["lib/*"],
      "@components/*": ["components/*"]
    }
  }
}
```

```json
{
  "compilerOptions": {
    // 移行後: pathsの値はtsconfig.jsonの位置からの相対。元のbaseUrl分を値へ足す
    "paths": {
      "@lib/*": ["./src/lib/*"],
      "@components/*": ["./src/components/*"]
    }
  }
}
```

また`baseUrl`はエイリアスを介さないimportの解決にも使われていた。そのため`import { x } from "src/foo"`のように書かれた箇所は、`baseUrl`の削除によって解決できなくなる。該当する記述が多い場合は、importをすべて書き換えるよりも`paths`へ項目を足すほうが差分は小さく済む。

```json
{
  "paths": {
    "@src/*": ["./src/*"],
    // baseUrl前提で書かれていた "src/..." からのimportを解決するために追加
    "src/*": ["./src/*"]
  }
}
```

ただし`paths`が作用するのは型検査の解決だけで、`tsc`は出力するimport文を書き換えない。実行時にも同じ解決が要るため、バンドラや実行環境側にも対応する設定が要る。

### typesの既定値が空になる

`types`は`node_modules/@types`以下のグローバル型定義のうちどれを読み込むかを指定する設定である。6系までの既定では、`node_modules/@types`にあるパッケージがすべて自動で読み込まれた。しかも上位ディレクトリの`node_modules/@types`まで遡って対象になっていた。`@types/node`を入れるだけで`process`が使えていたのはこの挙動による。

TypeScript 7ではこの既定値が空配列に変わり、列挙していない型定義は読み込まれない。そのため`@types/node`を導入していても、`types`に書かなければ`process`は解決できない。

```text
error TS2591: Cannot find name 'process'.
Do you need to install type definitions for node?
Try `npm i --save-dev @types/node` and then add 'node' to the types field in your tsconfig.
```

旧来の挙動は`types: ["*"]`を指定すれば復元できるが、それでは暗黙に読み込まれる型が元通り増えるため、パッケージが使う型を列挙するほうがよい。必要な型はパッケージの性質ごとに違うので、一律の値にはできない。例えば次のように分かれる。

| そのパッケージが必要とするもの | `types` |
| --- | --- |
| Node.jsのAPIとVitestのグローバル | `["node", "vitest/globals"]` |
| 上記に加えてReactの型 | `["node", "react", "vitest/globals"]` |
| Node.jsのAPIとChrome拡張のAPI | `["node", "chrome"]` |
| JestとVitestが併存する場合 | `["node", "jest", "vitest/globals"]` |
| Node.jsのAPIだけ | `["node"]` |

影響が出るのは`types`を書いていなかったパッケージである。すでに`types`を指定していた構成では、6系の時点でも列挙したものだけがグローバルに入っていたため、既定値の変更そのものには影響されない。

## 型検査で表面化したコード側の問題

### CSSのside-effect importに宣言が要る

side-effect importとは、値を受け取らず読み込みの副作用だけを期待するimportのことであり、CSSの読み込みがこれにあたる。

```ts
import "./styles.css";
```

TypeScript 7は宣言のないside-effect importを拒否する。

```text
error TS2882: Cannot find module or type declarations for side-effect import of './styles.css'.
```

そこでCSSを扱うフロントエンドの各パッケージには、モジュールの存在だけを伝える型宣言ファイルが要る。

```ts
// types/assets.d.ts
declare module "*.css" {}
```

### 型定義の更新で合わなくなった実装

移行にあわせて型定義も更新されるため、既存の実装が新しい宣言と一致しなくなる箇所が出てくる。たとえばテスト用に`IntersectionObserver`を差し替えていた実装は、標準ライブラリ側に`scrollMargin`が増えたことでプロパティ不足になった。モックが実装すべき形は標準ライブラリの宣言が決めるため、宣言の更新にはそのつど追随する必要がある。

```ts
global.IntersectionObserver = class IntersectionObserver {
  // 標準ライブラリの宣言に合わせて追加
  scrollMargin = '';
  thresholds = [];
};
```

URLのパスから言語コードを受け取る実装も同じである。`/ja/products`のようにパスの先頭で言語を切り替える構成では、その部分がルーティングの引数として渡ってくる。移行前はこれをリテラル型の直和で受けていたが、URLには任意の文字列が入る以上、型で絞っても実体は保証されない。`string`で受けて実行時に検証する形が要る。

```tsx
// 移行前: 型の上ではLocaleSegmentだが、実行時には任意の文字列が来る
type Props = { params: Promise<{ locale: LocaleSegment }> }

const { locale } = await params
const t = await getTranslator("common", toAppLocale(locale))
```

```tsx
// 移行後: stringで受けて実行時に検証し、不正なら404を返す
type Props = { params: Promise<{ locale: string }> }

const { locale: localeParam } = await params
const locale = normalizeLocaleSegment(localeParam)
if (!locale) notFound()
const t = await getTranslator("common", toAppLocale(locale))
```

### コミット済みのビルド成果物が差分になる

ESLintのローカルルールは、TypeScriptで書いたものを`tsc`でトランスパイルし、生成した`.js`をリポジトリへコミットする運用になっていた。そのため`target`を`es5`から`ES2022`へ変えたことで、生成物の出力形式そのものが変わっている。

```js
// 移行前: target es5 の出力
var noMuiBoxUsage = utils_1.ESLintUtils.RuleCreator.withoutDocs({
  create: function (context) {
    return {
      ImportDeclaration: function (node) {
```

```js
// 移行後: target ES2022 の出力
const noMuiBoxUsage = utils_1.ESLintUtils.RuleCreator.withoutDocs({
  create: context => {
    return {
      ImportDeclaration(node) {
```

出力形式が変わっただけで意図した挙動は同じだが、レビュー対象の差分は増える。生成物をコミットしていると、コンパイラ設定の変更がそのまま差分として現れる。

### 型定義パッケージと標準ライブラリの衝突

`@types/node`のメジャー版によっては、TypeScript 7の標準ライブラリと同じグローバル宣言が重複する。これはWeb標準として定義されたAPIがNode.jsにも実装されたことで起きる。TypeScriptの標準ライブラリはブラウザ側の仕様に沿って宣言を持ち、`@types/node`はNode.js側の実装に沿って宣言を持つ。どちらもグローバルスコープへ入るため、同じ名前に対して形の違う宣言が2つ並ぶ。

例えば`URLPattern`の宣言が両側にある組み合わせでは、型検査が次のように失敗する。

```text
error TS2403: Subsequent variable declarations must have the same type.
```

これは後から現れた宣言が先の宣言と同じ型になっていない、という意味のエラーである。どちらの宣言が正しいかにかかわらず、同じ名前に対して型の違う宣言が並んでいること自体が問題になる。エラーは型定義ファイルの中で報告されるため、`skipLibCheck: true`を指定すれば消える。ただし`skipLibCheck`は型定義ファイル全体の検査を止めてしまい、依存が持つ他の宣言の誤りも同時に見逃す。衝突を隠す用途には向かない。

対処としては、型定義のメジャー版を実行環境のNode.jsに合わせて下げ、標準ライブラリと衝突しない組み合わせにするのがよい。型定義パッケージの版はコンパイラの標準ライブラリと組み合わせて決まるため、コンパイラの版だけを合わせても解決しない。
