shibomb

フラグ乱立の表示バグを撲滅!TypeScript判別可能ユニオンのUI設計

「読み込み中なのにデータが表示される」「エラーなのに完了画面が出ている」。そんな表示バグに悩んだことはありませんか。原因の多くは、isLoading や isError、data といった複数の変数を並べて状態管理していることにあります。この記事では TypeScript の 判別可能なユニオン型 を使い、ありえない状態をそもそも作れない設計にする方法を、コード付きで解説します。

「フラグ乱立」が引き起こすUI状態の矛盾とバグの正体

データ取得の状態を、次のように管理するコードはよく見かけます。

type State = {
  isLoading: boolean;
  isError: boolean;
  data: User | null;
  error: Error | null;
};

一見わかりやすいのですが、この型は不正な状態を許してしまいます。isLoading と isError はそれぞれ true か false、data と error は値があるかないかで、組み合わせは単純計算で 16 通りです。一方、画面で意味を持つ状態は「読み込み中」「成功」「失敗」の 3 つ程度です。残りの十数通りは、型の上では存在できてしまう矛盾した状態です。

典型的なバグは、状態の更新漏れで起きます。再取得を始めるときに isLoading を true にしても isError を false に戻し忘れると、ローディング表示とエラー表示が同時に出ます。逆に成功時に error をクリアし忘れると、古いエラーが残ります。コードを書く人が毎回「全フラグを正しく更新する」責任を負うため、機能追加やチーム開発のたびにバグが入り込みやすくなります。

根本原因は、本来 1 つの「状態」であるものを、独立した複数の変数で表現していることです。変数が独立している以上、バラバラに更新でき、矛盾も生まれます。

判別可能なユニオン型(Discriminated Unions)の基本と仕組み

判別可能なユニオン型は、複数の型を | でつなぎ、それぞれに共通する「判別用のプロパティ」を持たせたものです。このプロパティを判別子(タグ)と呼び、値には文字列リテラルを使うのが一般的です。TypeScript 公式ハンドブックでも、この形式が型の絞り込み(narrowing)の手法として紹介されています。

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rect"; width: number; height: number };

function area(shape: Shape): number {
  if (shape.kind === "circle") {
    // ここでは shape は circle 型として扱われる
    return Math.PI * shape.radius ** 2;
  }
  // ここでは shape は rect 型に絞り込まれている
  return shape.width * shape.height;
}

shape.kind を比較した時点で、TypeScript が分岐内の型を自動的に絞り込みます。circle の分岐で shape.width にアクセスするとコンパイルエラーになります。「その状態にないプロパティには触れない」ことを、型が保証してくれるわけです。

この仕組みは TypeScript 2.0 で導入されました。現在では広く使われており、ライブラリの型定義でも見かけます。たとえば TanStack Query のクエリ結果も、status というプロパティで状態を判別する設計になっています。

実践:ローディング・成功・失敗を排他的な1つの型でモデリングする

先ほどのフラグ群を、判別可能なユニオン型で書き直します。再利用できるよう、ジェネリクスで汎用化しましょう。

type AsyncState<T, E = Error> =
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: E };

この型では、状態は 3 つのうち必ず 1 つです。loading には data がなく、error には data がありません。「読み込み中なのにデータがある」状態は、型の上で表現できなくなります。不整合をテストで見つけるのではなく、コンパイル時に作れなくするのがこの設計のポイントです。

更新も楽になります。状態を丸ごと置き換えるだけで、フラグの戻し忘れが起きません。

setState({ status: "loading" });
setState({ status: "success", data: user });
setState({ status: "error", error: new Error("取得に失敗しました") });

「再取得中は前回のデータを表示したまま、小さなインジケーターを出したい」という要件もよくあります。この場合は、フラグを足さずに状態を増やします。たとえば { status: "refreshing"; data: T } を加えます。要件を型で明示すれば、チームの誰が読んでも仕様がわかります。まだ何も取得していない初期状態が必要なら、{ status: "idle" } を加えるのも定番です。

switch文の網羅性チェック(never型)で画面の考慮漏れを完全ガード

状態を型にしたら、分岐の書き漏らしも型で防げます。使うのが never 型です。すべての状態を処理した後の default では、変数の型が never(ありえない型)に絞り込まれます。その性質を利用します。

function assertNever(value: never): never {
  throw new Error(`未処理の状態です: ${JSON.stringify(value)}`);
}

function render(state: AsyncState<User>): string {
  switch (state.status) {
    case "loading":
      return "読み込み中...";
    case "success":
      return `ようこそ、${state.data.name}さん`;
    case "error":
      return `エラー: ${state.error.message}`;
    default:
      return assertNever(state);
  }
}

ここで AsyncState に refreshing を追加したとします。render の default に到達する state は never ではなく refreshing 型になるため、assertNever(state) の呼び出しでコンパイルエラーが出ます。つまり、状態を増やした瞬間に、対応が必要な画面の場所がエディタ上で赤く示されるのです。

フラグ方式では、新しい状態を追加しても、どこを直すべきかは人間が検索して探すしかありませんでした。網羅性チェックがあれば、修正漏れが実行時のバグとして表に出る前に気づけます。これはチーム開発や長期保守で特に効果を発揮します。

保守しやすいReactコンポーネント・カスタムフックへの落とし込み方

最後に、React での実装例です。データ取得をカスタムフックに閉じ込め、状態の型を返します。

type User = { id: string; name: string };

function useUser(id: string): AsyncState<User> {
  const [state, setState] = useState<AsyncState<User>>({ status: "loading" });

  useEffect(() => {
    const controller = new AbortController();
    setState({ status: "loading" });

    fetch(`/api/users/${id}`, { signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json() as Promise<User>;
      })
      .then((data) => setState({ status: "success", data }))
      .catch((e) => {
        if (controller.signal.aborted) return;
        setState({
          status: "error",
          error: e instanceof Error ? e : new Error(String(e)),
        });
      });

    return () => controller.abort();
  }, [id]);

  return state;
}

id が変わったときは、クリーンアップで前のリクエストを AbortController で中断します。古いレスポンスが新しい状態を上書きする競合も防げます。コンポーネント側は、状態に応じて表示を切り替えるだけです。

function UserPage({ id }: { id: string }) {
  const state = useUser(id);

  switch (state.status) {
    case "loading":
      return <Spinner />;
    case "error":
      return <ErrorMessage message={state.error.message} />;
    case "success":
      return <UserCard user={state.data} />;
    default:
      return assertNever(state);
  }
}

success の分岐の中でだけ state.data に触れる点に注目してください。data が null かどうかを確認する if は不要で、画面側のコードが読みやすくなります。

注意点もあります。実務のデータ取得では、キャッシュや再試行、重複リクエストの排除など考えることが多く、TanStack Query のような既存ライブラリを使うのが一般的です。それらも「状態を排他的な型で表す」考え方は同じです。自作のフックは、小規模なアプリや学習用と割り切り、判別可能なユニオン型の発想を設計全体に広げていくのがおすすめです。フォームの送信状態やモーダルの開閉、ウィザード形式の画面遷移にも応用できます。

関連記事