3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

isLoading が真なのに isError も真...みたいなのやめたい(判別可能なユニオン型で)

3
Posted at

単一のAPIコールの状態管理で、 isLoading && isError な状態ってあると思う? ないよね。

そういう矛盾したありえない状態って、ありえない状態だからこそ、本当はコード上でそもそも書けない状態にしておきたい。今回は、TypeScript の「判別可能なユニオン型」を用いて、この課題を解決するよ。

(要点は↑の動画に書いたけど、詳しいところを自分自身記事にまとめたかったので技術記事を書くに至った)

この記事はなに?

Reactで非同期の処理を書くとき、isLoadingisErrordata を並べて useState する書き方があるよね。今回の記事はこれをやめるための技術について紹介する。

const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [data, setData] = useState<Data | null>(null);

3行で頭出しをすると、こう。

  • isLoading / isError / data を並べる書き方をやめる話を書くよ
  • 状態を1つの型にまとめると、ありえない組み合わせがそもそも書けなくなるよ
  • 分岐の書き忘れをコンパイルエラーにするところまでやるよ

結論だけ教えて

仮に、動画の書き出し機能を備えたWebアプリケーションを題材にするなら...判別可能なユニオン型で次のように書ける。

// 動画を Lambda でレンダリングして S3 に保存。最後にURLを返す...という Web アプリを仮定
export type State =
  | { status: "init" }
  | { status: "invoking" }
  | { renderId: string; bucketName: string; progress: number; status: "rendering" }
  | { renderId: string | null; status: "error"; error: Error }
  | { url: string; size: number; status: "done" };

status を目印に 「この状態ならこのプロパティがある」を明確にしている。

これは書き出しの進捗を示しているから、書き出しリクエスト → 書き出し中 → 完了 or エラー を invokingrenderingdone or error の順で表現していて、まだボタンも押されていない最初の状態が init だよ。

進捗の progress は書き出しが始まらないと手に入らないから rendering の中にあるし、動画の url は完了してはじめて存在するから done の中にしかない。

判別可能なユニオン型ってなに?

TypeScriptのドキュメントだと Discriminated unions の章に載っているよ。

日本語で読みたいなら、サバイバルTypeScriptのこのページが分かりやすいと思う。

status のような共通のフィールドを目印にして、いまどのメンバーなのかを見分けられるユニオン型のことだよ。この目印を判別子(discriminant)と呼ぶんだって。英語だと tagged union と呼ばれることもあるみたい。

判別子にはリテラル型のフィールドを使うよ。上の型なら status: "init" のように、具体的な値が明確に決まっている文字列を設定している。定数じゃなくて string にしてしまうと区別できないから、型を広げないようにするんだよ。

boolean を並べる書き方だと、なにが困るの?

組み合わせが一気に膨らむんだよね。isLoading / isError / data の3つを並べると、状態は 2 × 2 × 2 で8通りになる。

02-booleans.png

const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [data, setData] = useState<Data | null>(null);

このうち意味があるのは4通りだけだよ。何もしていない(全部false)、読み込み中、成功、失敗。

残りの4通りは...

  • 読み込み中であり、かつ失敗でもある
  • 読み込み中なのにデータがある
  • 失敗したのにデータがある
  • 全部true

見ての通り、明らかに意味の矛盾したステータスだ。

矛盾した状況は、理想的にはそもそも書けないほうが望ましい。でも別個の boolean を並べた書き方だと、型として全部書けてしまうし、代入まで出来てしまう。明らかにおかしいはずの状況なのに、それを機械的に拒絶できていないんだ。だから boolean を個別に持たせて組み合わせるのはリスクがある。

しかも矛盾した boolean の組み合わせバグは、ちょっとした実装で気軽に埋め込めてしまう。

// 失敗したので、もう一度
const retry = () => {
  setIsLoading(true);
  fetchData();
  // isError を false に戻していない
};

<>
  {isLoading && <Spinner />}
  {isError && <ErrorBanner />}
</>

isLoading を立てたときに isError を下ろし忘れると、両方trueのまま画面が描かれる。例えばこんな感じだね

03-bug.gif

ローディング中なのにエラーメッセージが出てる。すっごく忙しいプロジェクトで、正常系を最優先に他を捨てて作ってる最中だったりするのかな...? なんて思っちゃうよね。親切に作るなら、ユーザーの混乱を招きかねないしローディングスピナーは消したい。そして開発者としては、そもそもこんな矛盾が生じうるようなコードを最初から書けない状況にしておきたい。

じゃあ、どう書くか

判別可能なユニオン型を使おう。状態を1つの型にまとめて、その状態でだけ使うデータを中に入れてしまう。

この記事の冒頭「結論だけ教えて」で見せた State がそれだね。

// 動画を Lambda でレンダリングして S3 に保存。最後にURLを返す...という Web アプリを仮定
export type State =
  | { status: "init" }
  | { status: "invoking" }
  | { renderId: string; bucketName: string; progress: number; status: "rendering" }
  | { renderId: string | null; status: "error"; error: Error }
  | { url: string; size: number; status: "done" };

boolean 3つ だと8パターンの組み合わせがあったけど、これで5パターンに絞り込めた。「読み込み中なのにデータがある」は、そもそも表現する手段が無くなっているよ。data に相当する urldone の中にしかないからね。

上記だと読み込み中を invokingrendering の2つに分けてあるけど、仮に isLoading に相当する概念を分ける必要がないタイプのユースケースなら、組み合わせはもっと減らせる。

逆に、今回のケースのように読み込み中をより厳密な意味合いに分けたいケースでも、boolean を並べるのではなく1つの判別可能なユニオン型でまとめるなら、組み合わせパターンの増大を防ぐことができるんだね。

判別可能なユニオン型を使った場合、if文で分岐した後に見える型はどうなる?

status で分岐すると、その中では他の状態のフィールドが見えなくなるよ。

if (state.status === "rendering") {
  state.progress; // number
  state.url;      // Property 'url' does not exist
}

05-narrow.gif

if に入る前は init, invoking, rendering, error, done の5状態が候補だけど、条件分岐 state.status === "rendering" を通った瞬間に、rendering 以外のケースが持つプロパティは見えなくなるんだね。

型注釈や型ガード関数も書かずに、条件分岐しただけで状態の絞り込みが効くのは便利だよね。

書き忘れをコンパイルエラーにする

判別可能なユニオン型は、ありえない状態を書けないようにするだけじゃない。switch文限定だけど、条件分岐の網羅漏れを防ぐこともできるんだ。

never を使うのがポイント。こう書く。

switch (state.status) {
  case "init":      return <Idle />;
  case "invoking":  return <Spinner />;
  case "rendering": return <Progress value={state.progress} />;
  case "done":      return <Result url={state.url} />;
  // "error" だけ書いていない
  default: {
    const _exhaustive: never = state;
    throw new Error(_exhaustive);
  }
}

06-exhaustive.gif

default に来た時点で、caseで潰しきれなかった候補が state に残っているよね。全部潰せていれば残りは never になるから、never の変数に代入できる。1つでも残っていれば代入できずに落ちる、という仕組みだよ。

上の例だと "error" を書き忘れているから、こう怒られる。

TS2322: Type '{ renderId: string | null; status: "error"; error: Error; }'
is not assignable to type 'never'.

エラーメッセージに、書き忘れているメンバーがそのまま出てくるのが親切だね。

こう書いておけば、あとから状態を1つ足したとしても安心だよ。State{ status: "cancelled" } を足すと、never を置いてある場所が全部コンパイルエラーになる。直す場所を型に教えてもらえるわけだね。

この書き方って特殊なの?

特殊じゃないよ。よく使うライブラリのAPIが、もうこの形になっている。

07-standard.png

  • TanStack Query v5 … status: "pending" | "error" | "success"isPending のようなbooleanは、status から導かれる値として提供されているよ
  • React Router v7 … useNavigation().state"idle" | "loading" | "submitting"。遷移中かどうかも、boolean ではなく1つの文字列で持つ形だね
  • TypeScriptの標準ライブラリ … Promise.allSettled() の戻り値の PromiseSettledResult そのものが判別可能なユニオン型。{ status: "fulfilled"; value }{ status: "rejected"; reason } だよ

状態遷移の順番も型で表現してみる

判別可能なユニオン型は、状態ごとのプロパティを適切に表現できるけど、その状態の遷移の順番までは表現できない。つまり、ここまでに書いたコードの例で言うと done から rendering に戻す操作も、型としては普通に書けてしまう。

遷移の順番まで型に載せたいなら、どの状態からどの状態へ行けるかを値として書いておく手があるよ。

type Status = State["status"];

const NEXT = {
  init:      ["invoking"],
  invoking:  ["rendering", "error"],
  rendering: ["rendering", "done", "error"],
  done:      ["init"],
  error:     ["init"],
} satisfies Record<Status, Status[]>;

satisfies は「この値がその型を満たしているか、確かめるだけ」の演算子だよ。

const NEXT: Record<Status, Status[]> = { ... } と型注釈で書いた場合、値の型は注釈のほうに合わせて広げられる。NEXT.initStatus[]、つまり「どの状態にでも行ける配列」ということになってしまうんだ。

satisfies なら検査だけして、推論された型はそのまま残してくれる。だから Record<Status, Status[]> としてキーの網羅を確かめてもらいながら、NEXT.init"invoking"[] のまま扱えるよ。

遷移の順番を示す定数(前述の NEXT )を定義しておけば、あとは実際の状態遷移の処理の前に次のようなチェックを入れればいい。

const canGo = (from: Status, to: Status) =>
  NEXT[from].some((s) => s === to);

canGo("done", "rendering");  // false
canGo("done", "init");       // true

08-machine.gif

実際に踏む順路は initinvokingrenderingdone で、途中で失敗したら error に落ちる。doneerror から行ける先は init だけにしてあるから、やり直すときは必ず init を経由することになるね。

satisfies を付けてあるから、State に状態を足したのに NEXT に書き足すのを忘れると、ここでコンパイルエラーになるよ。それでいて NEXT.init の型は "invoking"[] のまま残る(手元の TypeScript 5.9 で、わざと number に代入して型を吐かせて確かめたよ)。

その代わり、NEXT[from].includes(to) は通らない。NEXT[from] が状態ごとに別々の配列型なので、includes の引数が never になって is not assignable to parameter of type 'never' と怒られるんだ。上のように some で比べるか、一度 readonly Status[] に受け直せば通るよ。

状態の数が5つくらいなら、ここまでやらずに判別可能なユニオン型だけで止めておいてもいいと思う。ここから先が欲しくなったら XState みたいなステートマシンのライブラリを見るのが早いはず。

まとめ

  • 複数の boolean の組み合わせで状態を表現すると、パターンが増大する上に、実際にはありえない組み合わせのパターンまで許容してしまう
  • 判別可能なユニオン型を使えば、ありえない状態の組み合わせを回避して、ステータスごとの実際に許容される状態の組み合わせだけを表現できる(また違反を未然に防げる)
  • 判別可能なユニオン型と switch を組み合わせれば、never を使ってステータスの網羅漏れを防げる
  • 状態遷移の順番まで表現したくなったら、遷移表を値で持って satisfies で照合する手があるよ
3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?