0 views
B!0X

tsc v7対応記録

typescript-7-migration-record

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

TypeScript 7で変わった点

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と併存させる構成を案内している。7系コンパイラと6系互換APIを、別々の依存名で入れる。

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

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

依存の指定

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

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

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

{
  "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範囲を確認しておくと、更新が必要なツールを先に洗い出せるため、作業中に解決エラーで止まらずに済む。

npm view ts-jest peerDependencies.typescript

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

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

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

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

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

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 execnode_modules/.binを優先して解決し、足りないパッケージを自動で取得することはない。

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

削除された設定

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

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

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

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

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

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

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

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

baseUrlの削除はpathsの値を壊す

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

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

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

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

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

typesの既定値が空になる

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

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

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の読み込みがこれにあたる。

import "./styles.css";

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

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

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

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

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

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

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

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

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

const { locale } = await params
const t = await getTranslator("common", toAppLocale(locale))
// 移行後: 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をリポジトリへコミットする運用になっていた。そのためtargetes5からES2022へ変えたことで、生成物の出力形式そのものが変わっている。

// 移行前: target es5 の出力
var noMuiBoxUsage = utils_1.ESLintUtils.RuleCreator.withoutDocs({
  create: function (context) {
    return {
      ImportDeclaration: function (node) {
// 移行後: 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の宣言が両側にある組み合わせでは、型検査が次のように失敗する。

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

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

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