おもちかえり.com(Vue + Firebase)で、型の穴をまとめてふさぐ作業をしました。Firestore の data() を DocumentData のまま流している所、as で言い切っている所、type: Object のままの props などです。条件は一つだけで、「実行するコードは変えないで、型だけ直す」。
問題は、それをどう確かめるかです。テストが通っても、テストが見ていない所の動きが変わっていないとは言えません。レビューで「型しか触っていません」と書いても、それは書いた人の主張です。
型は実行時に消える。だから成果物を比べればよい
TypeScript の型注釈、as、generics、satisfies、import type は、どれもビルドで消えます。つまり本当に型しか触っていないなら、ビルド成果物は1バイトも変わらないはずです。逆に言えば、成果物が同じなら、実行時の動きが変わらないことはテストより強く言えます。
やることは単純で、変更前と変更後で同じようにビルドして比べるだけです。
# 変更前(main)
git stash
yarn build && cp -r dist /tmp/dist-main
git stash pop
# 変更後
yarn build && cp -r dist /tmp/dist-new
diff -rq /tmp/dist-main /tmp/dist-new && echo "DIST IDENTICAL"
PR の本文には「main とこのブランチの dist を diff -rq で比べて同じ」と一行書きます。レビューする側は、型の一行一行を「本当に実行時に影響しないか」と疑わずに済みます。
要らない as は機械に消させる
as の中には、外しても型が変わらないものが混ざっています。これは typescript-eslint の no-unnecessary-type-assertion が見つけてくれて、--fix で消せます。
npx eslint src --fix \
--rule '{"@typescript-eslint/no-unnecessary-type-assertion":"error"}'
「外しても型が変わらない」ものしか触らないので、これも成果物は変わりません。
残りの as は、外すと型が広がるものです。ここは一つずつ試すと時間がかかるので、まとめて外し、typecheck が落ちたファイルだけ元に戻すのを繰り返しました。外した行を JSON に記録しておくと、戻すのも機械的にできます。落ちなかったファイルは、代入先が広い型をもともと受けていたということなので、そのまま外せます。
ハッシュの連鎖で「全部違う」に見えるとき
Vite の出力はファイル名に中身のハッシュが付きます。一つのチャンクが変わると、それを import しているチャンクのハッシュも変わり、diff -rq では全部のファイルが違って見えます。
このときは、ファイル名と中身の両方からハッシュを剥がしてから比べます。
H = re.compile(r"-[A-Za-z0-9_-]{8}(?=\.(?:js|css|map)\b)")
# 名前も中身も H.sub("", ...) してから、ファイルごとに比べる
eslint-disable-next-line を消す変更でこれをやったら、違いは ()=>{} が ()=>void 0 になった所だけでした。どちらも undefined を返すだけの関数です。new X() を void new X() にした所は、縮める段階で void が消えて、出力は同じでした。違いが残ったときは、その違いを読めば済む、というところまで持っていけます。
同じにならない変更もある
Firestore の data() as Model を、値をそのまま返す関数に置き換えた所もあります。
// 値には触れず型だけを当てる。当て場所をここ一箇所に集める。
export const collectionData = <T = DocumentData>(data: DocumentData): T =>
data as T;
これは関数の呼び出しが増えるので、成果物は変わります。この手の変更では「成果物が同じ」とは言えないので、PR に「増えるのは引数をそのまま返す関数の呼び出しだけ」と書き、関係する e2e を回し、別のモデル(Codex)にもレビューさせました。証明できる変更と、説明するしかない変更を PR ごとに分けておくと、レビューの重さを変えられます。
気をつけること
- 成果物が同じなら、動きは変わっていません。ただし、付けた型が正しいかどうかまでは分かりません。型の正しさは、書き手(Firestore に書き込むコード)と見比べるしかありません。
- 比べるのは同じ手順でビルドした成果物同士にします。環境変数やビルドの設定が違えば、型に関係なく出力は変わります。
ref()に型を付けるついでに、初期値をundefinedからnullにしてしまうと、型の直しに見えて値の変更です。今回は書いている途中で気づいて戻しましたが、戻さなければ成果物の比較で落ちていたはずです。
型の直しは、量が多いわりに一つ一つは地味で、レビューが流し読みになりがちです。「成果物が同じ」は、読まずに信じてよい数少ない主張です。
