2 / 3

あってはならない組み合わせは型で防ぐ

人の注意力に頼っていたルールは、注意力を持たない側がコードを書いた瞬間に崩れる。

最初

プロフィール画面の状態はフィールド三つだった。ローディング中かどうか、データ、エラー。

作った人は几帳面だった。成功したら loading を落とし、失敗したら loading を落として error を入れた。一度も漏らしたことはなかった。

profileState.ts
type State = {
  loading: boolean;
  data?: User;
  error?: Error;
};

その間に

状態を変える場所が増えた。初回ロード、引っ張って更新、30秒ごとのポーリング、編集保存後の再取得、別タブでログアウトしたときの初期化。

五か所、五人。ある人は更新中を表示するために isRefreshing を別に作った。これで「ロード中」を意味するフィールドが二つになった。この五か所すべてが同じルールを守らなければいけないと知っているのは、最初に作った一人だけだった。

profileState.ts
type State = {
  loading: boolean;
  isRefreshing: boolean;
  data?: User;
  error?: Error;
  lastFetchedAt?: number;
};
useProfile.ts
setState({ ...state, loading: true });
const data = await fetchProfile();
setState({ ...state, loading: false, data, lastFetchedAt: Date.now() });
PullToRefresh.tsx
setState({ ...state, isRefreshing: true });
await refetch();
setState({ ...state, isRefreshing: false });
usePolling.ts
useEffect(() => {
  const id = setInterval(refetch, 30_000);
  return () => clearInterval(id);
}, []);
EditProfile.tsx
await saveProfile(form);
setState({ ...state, loading: true });
refetch();
AuthListener.ts
onSignOut(() => {
  setState({ loading: false, isRefreshing: false });
});

3か月後、六か所目

「エラーになったら再試行ボタンを出してください。」

新しく来た開発者はAIに頼んだ。AIは五か所のうち一つを参考に、catch ブロックをきれいに書き直してくれた。それらしく見えたし、型チェックも通った。

useProfile.ts
catch (e) {
  setState({ ...state, error: e });
  // loading は true のまま
}

金曜の夜

APIサーバーが少し不安定になった。ユーザーの画面ではスピナーが回り続け、そのすぐ下にエラーメッセージと再試行ボタンが一緒に出ていた。

再試行を押すと、スピナーの下にもう一つスピナー。Slackにスクリーンショットが貼られた。

なぜこうなったか

型は「ローディング中かつエラー」という状態を許していた。フィールドが五つ、変える場所が六か所になると、二つが同時にあってはいけないというルールを毎回覚えておくのは人にも難しい。

AIにはその記憶すらなかった。許されている中から一番それらしいコードを選んだだけだ。

壊れる前に

ありうる状態だけをユニオンで列挙する。ローディング中かつエラーという状態は、もう書くことすらできない。

AIが同じ間違いをしても、今度はビルドが先に止まる。

profileState.ts
type State =
  | { status: "loading" }
  | { status: "success"; data: User }
  | { status: "error"; error: Error };
ProfileView.tsx
switch (state.status) {
  case "loading": return <Spinner />;
  case "success": return <Profile user={state.data} />;
  case "error":   return <ErrorView error={state.error} />;
}