コース目次 / 第8章

判別可能ユニオン

通信の3状態を型で設計して、あり得ない状態を書けなくします。

第8章 / 全11章目安 約85分この章のゴール: 読み込み中・失敗・再試行が画面に出て、機内モードでも死なないUIになる

経験者の方へ: JavaScriptに自信がある方は、Aパートは流し読みで構いません。事故が起きるところと、TSにするところから精読してください。

今日のゴール

第5章で放置した通信の失敗をここで回収します。読み込み中の表示、失敗のメッセージ、再試行ボタンが付きます。

今日つかまえるバグ: 「読み込み中」と「失敗しました」が同時に表示される事故。

ミニ解説その1(JS): boolean 2つで状態を持つと破綻する

通信の状態を素直に書くと、こうなりがちです。

よくある書き方
let isLoading = false;
let hasError = false;

// 開始
isLoading = true;
// 失敗したら
hasError = true;
// …isLoading を false に戻し忘れると?

戻し忘れると isLoading === true かつ hasError === true になります。画面には「読み込み中…」と「失敗しました」が同時に出ます。

booleanが2つあると、組み合わせは4通り。そのうち1通りは、現実には存在しないはずの状態です。バグは、たいていこの「あり得ないはずの状態」に潜みます。

try/catch(初出)

失敗を受け止める構文です。

try {
  const rows = await fetchRates(CURRENCIES); // 失敗するかもしれない行
} catch (error) {
  // 失敗したときにここへ来る
}

守備範囲はawait の行しか失敗しない」と単純化して覚えてください。通信以外は基本的に失敗しません。

ミニ解説その2(TS): 判別可能ユニオン

「あり得ない状態を書けなくする」——そのための型設計がこれです。

3つの状態を並べる
type FetchState =
  | { status: 'loading' }
  | { status: 'success'; data: Rate[] }
  | { status: 'error'; message: string };

読み方はこうです。「読み込み中の便」「荷物を積んだ成功の便」「理由を持った失敗の便」のどれか1つ。搭乗券のステータス欄だと思ってください。

大事なのは、status という共通の名札があることです。これを 判別プロパティ と呼びます。

switchで分けると、その中では型が変わる
switch (state.status) {
  case 'loading':
    // ここでは state に data はありません
  case 'success':
    // ここでは state.data が使えます
}

case の中に入ると型が絞られます。 これもナローイングです(第4章の考え方がそのまま効いています)。ホバーして確かめてみてください。

あり得ない状態は、そもそも書けません。{ status: 'loading', message: '...' } と書こうとすると赤線が出ます。読み込み中の便に荷物は載っていないからです。

Aパート: JSで書く

新しいファイル src/state.js を作ります。状態から表示する文字列を決める係です。

src/state.js
export function showState(target, state) {
  target.textContent = describe(state);
}

export function describe(state) {
  switch (state.status) {
    case 'loading':
      return '読み込み中…';
    case 'success':
      return `${state.data[0].date} のレートで計算しています`;
    case 'error':
      return `雲行きが怪しいようです… (${state.message})`;
  }
}

exchange.js を、状態を持って描き分けるように直します。再試行ボタンも足します。

src/exchange.ts+2 / -0
 import { parseAmount, toCurrency } from './parse'; import { fetchRates, findRate } from './rates';+import type { Rate } from './rates';+import { showState } from './state';  const CURRENCIES = ['TWD', 'EUR', 'USD'];

行頭の + - は変更の目印です。コピーすると、記号と削除された行を除いたこの章を終えた時点の内容が入ります。

src/exchange.ts+13 / -0
 } +async function loadRates(note: Element): Promise<Rate[] | null> {+  showState(note, { status: 'loading' });+  try {+    const rows = await fetchRates(CURRENCIES);+    showState(note, { status: 'success', data: rows });+    return rows;+  } catch (error) {+    const message = error instanceof Error ? error.message : '原因が分かりません';+    showState(note, { status: 'error', message });+    return null;+  }+}+ export async function setupExchange(): Promise<void> {   const box = document.querySelector('#exchange')!;

行頭の + - は変更の目印です。コピーすると、記号と削除された行を除いたこの章を終えた時点の内容が入ります。

src/exchange.ts+1 / -0
     <p class="result" id="result"></p>     <p class="note" id="rate-note"></p>+    <button class="secondary" id="retry">再試行</button>   `;

行頭の + - は変更の目印です。コピーすると、記号と削除された行を除いたこの章を終えた時点の内容が入ります。

src/exchange.ts+9 / -2
   const result = document.querySelector('#result');   const note = document.querySelector('#rate-note');-  if (amountInput === null || currencySelect === null || result === null || note === null) {+  const retry = document.querySelector('#retry');+  if (+    amountInput === null ||+    currencySelect === null ||+    result === null ||+    note === null ||+    retry === null+  ) {     return;   } -  const rows = await fetchRates(CURRENCIES);+  let rows = await loadRates(note);    const update = () => {

行頭の + - は変更の目印です。コピーすると、記号と削除された行を除いたこの章を終えた時点の内容が入ります。

src/exchange.ts+6 / -0
     } +    if (rows === null) {+      result.textContent = 'レートがまだありません';+      result.classList.add('error');+      return;+    }+     const row = findRate(rows, code);     if (row === undefined) {

行頭の + - は変更の目印です。コピーすると、記号と削除された行を除いたこの章を終えた時点の内容が入ります。

src/exchange.ts+5 / -1
     const yen = convert(amount, 1 / row.rate);     result.textContent = `${amount.toLocaleString()} ${code} = ¥${yen.toLocaleString()}`;-    note.textContent = `${row.date} のレートで計算しています`;   }; +  retry.addEventListener('click', async () => {+    rows = await loadRates(note);+    update();+  });+   amountInput.addEventListener('input', update);   currencySelect.addEventListener('change', update);

行頭の + - は変更の目印です。コピーすると、記号と削除された行を除いたこの章を終えた時点の内容が入ります。

ここまでがAパートです。

事故を踏む

開発者ツールの Network タブを開き、Offline(オフライン)に切り替えてリロードしてください。DevToolsの小出し第4弾です。

いまのコードなら、失敗のメッセージと再試行ボタンが出ます。もしここで describe の分岐を書き間違えていたら、読み込み中の表示が消えないまま失敗メッセージが出る、という気まずい画面になります。

オンラインに戻すのを忘れないでください。

Bパート: TSにする

state.jsF2state.ts にリネームします。

state.js → state.ts型だけの変更+9 / -2
-export function showState(target, state) {+import type { Rate } from './rates';++export type FetchState =+  | { status: 'loading' }+  | { status: 'success'; data: Rate[] }+  | { status: 'error'; message: string };++export function showState(target: Element, state: FetchState): void {   target.textContent = describe(state); } -export function describe(state) {+export function describe(state: FetchState): string {   switch (state.status) {     case 'loading':       return '読み込み中…';     case 'success':       return `${state.data[0].date} のレートで計算しています`;     case 'error':       return `雲行きが怪しいようです… (${state.message})`;   } }

行頭の + - は変更の目印です。コピーすると、記号と削除された行を除いたこの章を終えた時点の内容が入ります。

switch の各 case の中で state にホバーしてください。case 'success' の中だけ data を持っていることが見えます。

catch (error) の中身は unknown

第7章の即回収です。catch で受け取る値の型は unknown です。

JavaScriptでは throw何でも投げられます(文字列でも数字でも)。だからTypeScriptは「何が飛んでくるか分からない」として unknown にします。ここでも、確かめてから使います。

確かめてから使う
const message = error instanceof Error ? error.message : '原因が分かりません';

網羅性(コラム)

switch にすべての case を書くと、TypeScriptは「全部尽くされた」と分かります。あとから状態を1つ足すと、書き漏らした場所が赤線になります。 これを厳密に保証したいときは never という型を使いますが、いまは「全部書けば守られる」で十分です。

わざと壊してみる

describe に渡す状態のスペルを間違えてみてください。

src/exchange.ts(この行だけ差し替える)
showState(note, { status: 'eror' });

ここで赤くなるのが正解です。 リテラル型なので、代入した時点で間違いが分かります。確認したら戻します。

あなたの番

  • エラーの文言に人格を持たせてください。見本は「雲行きが怪しいようです…」です。
  • 'idle'(まだ何もしていない)という状態を足してみてください。 FetchState に1行足すと、switch が「その場合の処理が無い」と赤線を出します。型が、直すべき場所を全部教えてくれます。これがこの章の隠れた主役です。
  • 合格条件: 変えたあとも npm run check がエラー0。

実務メモ

これは React Query の status そのものであり、useReducer の state 設計そのものです。この FetchState は、今日そのまま自分のReact Nativeアプリに持ち帰れます。

TSエラー語辞典

この章で実際に出るエラー英文と、その日本語訳です。卒業するころには全章ぶんがお助けページに集まります。

エラー英文 言っていること
Property 'data' does not exist on type '{ status: "loading"; }' 読み込み中の便に荷物はまだ載っていません
`Type ‘“eror”’ is not assignable to type ’“loading” “success”

ハマったら

分割代入するとナローイングが効かない

const { status, data } = state; と取り出すと、絞り込みが効かなくなります。switch の中では state.data と書く、と定型で固定してください。理屈は深追いしなくて大丈夫です。

機内モードを戻し忘れた

開発者ツールのNetworkタブが Offline のままだと、この先の章でずっと通信が失敗します。No throttling に戻してください。

ZIPで戻したい

このページの下には2つのZIPがあります。JS版はAパート(JSで動いた状態)まで、TS版は章末(TS化まで終わった状態)です。詰まった場所に合わせて選んでください。

解凍したら、フォルダの中で次の2つを順に打ちます。

ZIPを解凍したフォルダの中で
npm installnpm run dev

行頭の $ は「ここからコマンド」という目印です。$ は打たないでください。

丸ごと置き換えると自分で書き換えた内容が消えます。 まず src/places.ts(自分の行き先データ)と、自分で変えた文言をメモ帳に待避してから、壊れたファイルだけを差し替えてください。

こうなっていればOK

うまくいかないときは、この章のファイル一式をダウンロードして続きから進めても大丈夫です。JS版(TS化する前)TS版(章末)の2つがあります。

卒業まであと2章です。

この章はまだ完了していません。