本文へスキップ

既存プロジェクトに TypeScript の strict を段階的に入れる

約 2 分で読めます

tsconfig.jsonstrict: true を有効にした瞬間、エラーが 1,200 件出た——という状況から始めた移行の記録です。結論としては、strict をひとつのスイッチとして扱わないのが要でした。

strict は 8 個のフラグの束

strict: true は単独のオプションではなく、複数のフラグをまとめて有効にするエイリアスです。逆に言えば、個別に有効化できます。

{
  "compilerOptions": {
    // strict: true を分解して、通るものから入れていく
    "noImplicitThis": true,
    "alwaysStrict": true,
    "strictBindCallApply": true,
    "strictFunctionTypes": true,
    "noImplicitAny": false, // 残り
    "strictNullChecks": false, // 残り
    "strictPropertyInitialization": false, // 残り
  },
}

実際に試したところ、エラー件数の内訳は極端に偏っていました。

フラグ エラー件数
strictNullChecks 約 900
noImplicitAny 約 250
strictPropertyInitialization 約 40
その他 4 つ合計 12

つまり その他 4 つは即日で入れられる。ここを先に確定させると、以後の差分に対しては最初から制約が効きます。

strictNullChecks はディレクトリ単位で

最大の山である strictNullChecks は、一括では終わりません。移行対象のディレクトリだけを見る別の設定ファイルを用意し、CI で二重にチェックしました。

// tsconfig.strict.json
{
  "extends": "./tsconfig.json",
  "compilerOptions": { "strictNullChecks": true, "noEmit": true },
  "include": ["src/domain/**/*", "src/lib/**/*"],
}
{
  "scripts": {
    "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.strict.json"
  }
}

include に 1 ディレクトリずつ足していくと、進捗が行数ではなく「あと何ディレクトリ」で見えるようになります。チームで分担するときにも切り分けやすい単位でした。

直し方で気をつけたこと

エラーを黙らせる方法はいくつもありますが、後で効いてくるものとそうでないものがあります。

非 null アサーション ! は原則使わない。 user!.name は型検査を止めるだけで、実行時の undefined は素通りします。移行中に入れた ! は、結局あとで例外として跳ね返ってきました。

早期リターンで絞る。 大半のエラーは、関数の頭でガードを 1 行足せば解決します。

// Before: user が undefined のときに落ちる
function greet(user?: User) {
  return `こんにちは、${user.name} さん`;
}

// After: 呼び出し側に判断を返す
function greet(user?: User) {
  if (!user) return 'こんにちは';
  return `こんにちは、${user.name} さん`;
}

外部データは境界で検証する。 API レスポンスに as ResponseType を付けるのは、型検査を欺いているだけです。境界に zod のようなバリデーターを置き、そこから先は検証済みの型として扱うと、strictNullChecks 由来のエラーがまとめて消えます。

結果

3 か月かけて全ディレクトリを移行しました。副作用として、移行中に 本番で起きうる undefined 参照が 17 箇所見つかっています。うち 3 箇所は実際に問い合わせが来ていた不具合でした。

型を厳しくする作業は地味ですが、「まだ起きていないバグを探す」作業としては費用対効果が高い部類だと感じています。