tsc --noEmit が緑になった翌週に、本番で Cannot read properties of undefined が出ました。
移行そのものは順調だったのです。JavaScript のままだったユーティリティ層をひととおり .ts に変え、strict: true も入れ、CI も通っていました。手元では「終わった」と思っていました。
落ちた場所を追うと、外部 API のレスポンスを受け取る関数でした。型はちゃんと付いています。ただしその型は、私が書いたのでも Claude Code が推論したのでもなく、as ApiResponse と手で押し込んだ嘘でした。
型が付いていることと、安全であることは別でした。個人開発で四つのサイトを回している身としては、痛いところを突かれた気分でした。
tsc が緑でも安全にならない、四つの漏れ道
移行後のコードベースを調べ直して分かったのは、any は「消した」つもりでも別の姿で残るということでした。私の場合、漏れ道は四つに整理できました。
| 漏れ道 | tsc は通るか | 実際に起きたこと |
as による断定 | 通る | 外部レスポンスを as ApiResponse で押し込み、欠けたフィールドが undefined のまま流れた |
| 型定義のない依存パッケージ | 通る(noImplicitAny でも import 経由なら素通り) | @types がない小さなライブラリの戻り値が実質 any で、そのまま伝播した |
| ジェネリクスの既定値 | 通る | useState() や自作関数の型引数を省略し、推論が広がった |
catch 節の値 | 通る | unknown のまま扱わず error.message に触れる分岐が残った |
いずれも「エラーが出ないから気付けない」という共通点があります。移行の進捗を「変換したファイル数」で見ているかぎり、この四つは最後まで見えません。
進捗の指標を、ファイル数から型カバレッジに変える
そこで指標を入れ替えました。式レベルで型が付いている割合、いわゆる型カバレッジです。type-coverage を使えば一行で出ます。
npx type-coverage --detail --strict
# 12043 / 12876 94.53%
# src/lib/api-client.ts:42:15 - res
# src/lib/api-client.ts:58:9 - payload
--detail を付けると、any のまま残っている式が行番号付きで並びます。これが移行の実態でした。ファイル単位では 100% 変換済みなのに、式単位では 94.5% だったのです。残りの 5.5%、約 830 箇所が、先ほどの四つの漏れ道に分布していました。
数値そのものより、内訳が読めることに価値があります。漏れをファイル別に束ねると、手を入れる順番が決まります。
#!/usr/bin/env bash
# scripts/type-coverage-report.sh — any 残存をファイル別に集計する
set -euo pipefail
npx type-coverage --detail --strict 2>/dev/null \
| grep -E '^[^ ]+\.tsx?:[0-9]+:[0-9]+' \
| cut -d: -f1 \
| sort | uniq -c | sort -rn \
| head -20
私の場合、上位 5 ファイルに漏れの 6 割が集まっていました。全体を薄く直すより、この 5 ファイルの境界設計をやり直すほうが確実に速い、という判断がここで立ちます。移行は面ではなく、点から崩れていました。
任せて速かった作業
線引きの話をします。まず、Claude Code に任せて実際に速かったのは次の三つでした。
構文の機械的な置き換え。 module.exports から export への書き換え、require の解決、JSDoc から型注釈への移し替え。この層は判断がほとんど要らず、レビューも差分を眺めれば済みます。私は 1 ファイルずつではなく、ディレクトリ単位でまとめて頼んでいました。
推論可能な型の付与。 内部で完結する純粋関数、定数オブジェクト、リデューサーの戻り値。実装を読めば型が一意に決まるものは、人が書くより速く、しかも正確でした。
エラーの分類。 tsc --noEmit の出力を貼って「同じ原因のものをまとめて」と頼むと、300 行のエラーが 8 つの原因に畳まれて返ってきます。これは体感でいちばん助かった使い方でした。エラーの海を眺めて途方に暮れる時間が、そのまま消えます。
# エラーを原因ごとに束ねてから着手する
npx tsc --noEmit 2>&1 | tee /tmp/tsc-errors.txt
claude -p "$(cat /tmp/tsc-errors.txt)
このエラーを原因ごとに分類し、影響ファイル数の多い順に並べてください。
各分類について、修正が型の付け替えで済むか、実装の変更を伴うかも判定してください。
コードはまだ書かないでください。" \
--allowed-tools "Read,Grep" \
--output-format text
--allowed-tools を読み取りだけに絞っているのは、分類の段階で書き換えを始められると、判断の材料が動いてしまうからです。調査と実行を分けると、あとで戻れます。
任せなかった判断
一方で、任せると静かに壊れたのが次の四つでした。ここが最初のクラッシュの正体です。
外部境界の型。 API レスポンス、環境変数、ファイル読み込み、ユーザー入力。ここは実行時に何が来るか型システムが知りようがない場所で、as は嘘の入口になります。Claude Code は指示すれば as を書いてくれますが、それはこちらが「嘘でいい」と言ったのと同じでした。
any を許す例外の判断。 「ここだけは any でいい」は、コストとリスクを天秤にかける判断です。私はこれを人の側に残しました。
型定義のない依存を、どう包むか。 素通しにするか、薄いラッパーを書くか、そもそも依存を外すか。プロジェクトの寿命に関わる判断で、目の前のエラーを消す最短路とは一致しません。
壊れたときに誰が困るか。 課金まわりのように、型エラーを握りつぶした結果が金額に出る箇所は、速度を捨てる価値があります。
境界の型は、断定ではなく検証に置き換えました。
// src/lib/api-client.ts
import { z } from "zod";
const ApiResponseSchema = z.object({
id: z.string(),
title: z.string(),
publishedAt: z.string().datetime(),
tags: z.array(z.string()).default([]),
});
export type ApiResponse = z.infer<typeof ApiResponseSchema>;
export async function fetchArticle(id: string): Promise<ApiResponse> {
const res = await fetch(`https://example.com/api/articles/${id}`);
if (!res.ok) {
throw new Error(`fetchArticle failed: ${res.status}`);
}
// as ではなく parse。型は「宣言」ではなく「検証の結果」として得る
return ApiResponseSchema.parse(await res.json());
}
as ApiResponse は「そういうことにする」でしたが、parse は「そうでなければ止まる」です。書く手間はほとんど変わらないのに、壊れ方がまったく違います。境界が 20 箇所ほどあったので、置き換えには半日かかりました。それ以降、同じ形のクラッシュは出ていません。
型の実体を z.infer から得ている点も、私は気に入っています。スキーマと型が二重管理にならず、片方だけ直して食い違う事故が起きません。
悪化を止めるゲートを CI に置く
線引きを決めても、それを守り続ける仕組みがなければ元に戻ります。実際、移行中の私は「今日は急ぐから」と一度 as を通していました。
そこで、型カバレッジの悪化そのものを PR で止めることにしました。100% を要求するのではなく、下がったら止める、という形です。
#!/usr/bin/env bash
# scripts/type-coverage-gate.sh — 型カバレッジの後退を PR 単位で拒否する
set -euo pipefail
BASELINE_FILE=".type-coverage-baseline"
TOLERANCE="0.10" # 誤差吸収(%ポイント)
CURRENT=$(npx type-coverage --strict 2>/dev/null \
| grep -oE '[0-9]+\.[0-9]+%' | tr -d '%')
if [ ! -f "$BASELINE_FILE" ]; then
echo "$CURRENT" > "$BASELINE_FILE"
echo "baseline を初期化しました: ${CURRENT}%"
exit 0
fi
BASELINE=$(cat "$BASELINE_FILE")
# 後退判定(tolerance ぶんは許す)
if awk -v c="$CURRENT" -v b="$BASELINE" -v t="$TOLERANCE" \
'BEGIN { exit !(c < b - t) }'; then
echo "型カバレッジが後退しました: ${BASELINE}% → ${CURRENT}%"
echo "any の追加箇所:"
npx type-coverage --detail --strict 2>/dev/null | grep -E ':[0-9]+:[0-9]+' | head -20
echo ""
echo "意図的な場合は .type-coverage-baseline を理由付きで更新してください。"
exit 1
fi
# 前進していれば baseline を引き上げて固定する(ラチェット)
if awk -v c="$CURRENT" -v b="$BASELINE" 'BEGIN { exit !(c > b) }'; then
echo "$CURRENT" > "$BASELINE_FILE"
echo "baseline を更新しました: ${BASELINE}% → ${CURRENT}%"
fi
echo "型カバレッジ: ${CURRENT}%(baseline ${BASELINE}%)"
肝は最後のラチェット部分です。前進したら baseline を引き上げて固定するので、一度上げた水準は自動的に守られます。目標値を人が管理しなくてよくなり、移行が止まっている週も静かに現状維持が働きます。
GitHub Actions に載せるとこうなります。
# .github/workflows/type-coverage.yml
name: type-coverage
on: [pull_request]
jobs:
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- run: npm ci
- run: bash scripts/type-coverage-gate.sh
# baseline が更新された場合はコミットして差分を残す
- name: commit baseline
if: success()
run: |
if ! git diff --quiet .type-coverage-baseline; then
git config user.name "github-actions"
git config user.email "actions@github.com"
git add .type-coverage-baseline
git commit -m "chore: raise type-coverage baseline"
git push
fi
例外を許すときは、baseline ファイルの更新理由をコミットメッセージに残すようにしました。「なぜ下げたか」が git log から読めるようになり、半年後の自分への説明になります。ゲートは通せなくするための壁ではなく、通した理由を残すための記録装置だと考えています。
分割の単位は、ファイルではなく境界
Claude Code に移行を頼むとき、最初は「このディレクトリを TypeScript にして」と投げていました。うまくいく日もあれば、大量の as が返ってくる日もあり、結果が安定しません。
原因ははっきりしていて、依頼の単位が実装の都合(ディレクトリ)であって、型の都合(境界)ではなかったからでした。
分け方を変えました。
| 層 | 依頼の仕方 | レビューの重さ |
| 内部ロジック(純粋関数・定数・リデューサー) | まとめて任せる。型は推論に委ねる | 差分を眺める程度 |
| 境界(API・env・ファイル・入力) | スキーマを人が先に書き、実装だけ任せる | 1 箇所ずつ読む |
| 金額・課金に触れる経路 | 人が書く。Claude Code はレビュー役に回す | テストとセットで確認 |
私自身、境界のスキーマを先に書いてから内部を任せるようにしてから、返ってくるコードの質が安定しました。任せた側に嘘をつく余地がなくなるからです。型が上流で確定しているので、下流は推論するしかないのです。制約を先に置くほうが、自由に書かせるより速く終わりました。
この考え方は移行に限りません。worktree で並列に走らせる場合も、分割の切れ目を境界に合わせておくと、あとの統合で衝突しにくくなります。
三ヶ月後に残ったもの
型カバレッジは 94.5% から 99.2% になりました。残りの 0.8% は、型定義のない依存を包んだラッパーの内側に集めてあります。すべて // type-coverage:ignore-next-line を付け、なぜ許したかを一行添えました。
数字より効いているのは、ゲートが静かに動き続けていることです。急いだ日に as を書こうとすると CI が止まり、そこで一度立ち止まれます。判断を毎回する必要がなくなり、判断そのものが仕組みに移りました。
はじめの失敗は、Claude Code に任せすぎたことではありませんでした。何を任せているのかを、自分が言葉にできていなかったことでした。任せる範囲が曖昧なまま速く進むと、速く壊れます。
今から始めるなら、まず npx type-coverage --detail --strict を一度走らせてみてください。すでに移行を終えたつもりのコードベースほど、出てくる行数に驚くはずです。その数字が、線引きを引き直す最初の材料になります。
移行の途中で立ち止まる材料になれば嬉しく思います。