コース目次 / 第7章

unknownと型ガード

外から来るデータは信じない。unknownで受けて、型ガードで確かめてから使います。

第7章 / 全11章目安 約85分この章のゴール: 行き先を追加・削除でき、保存される。壊れた保存データでも落ちない

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

今日のゴール

行き先を自分で追加・削除できるようになり、その内容が保存されます。そして保存データが壊れていても、画面は落ちません。

今日つかまえるバグ: 古い形式の保存データを読んで Cannot read properties of undefined でクラッシュする事故。

ミニ解説その1(JS): 境界の外は信用できない

自分が書いたコードの中の値は、形が分かっています。でも次の2つは違います。

  • APIから返ってきたデータ(相手のサーバが仕様を変えるかもしれない)
  • localStorageに入っていたデータ(前のバージョンの自分が入れたかもしれない)

これらは 「型システムの外」から来ます。TypeScriptは、外から来るものの中身を知る手段を持ちません。

確かめるのに使う道具は、実は全部むかしからあるJavaScriptの演算子です。

書き方 確かめること
typeof x === 'string' 文字列かどうか
'name' in x その名札があるかどうか
Array.isArray(x) 配列かどうか

ミニ解説その2(TS): any と unknown

res.json() や JSON.parse() が返す型は any です。

any は「検査を放棄する」。unknown は「検査を要求する」。
any は何をしても怒られません(だから危ない)。unknown は確かめるまで何もさせてくれません(だから安全)。

確かめる関数を 型ガード と呼びます。書き方はこうです。

型ガードの形
function isPlace(value: unknown): value is Place {
  // ...確かめる処理...
}

戻り値の型が boolean ではなく value is Place になっています。読み下すとこうです。

「この関数が true を返したなら、その value は Place だと検査官に伝えてください」

旅のたとえでいえば、手荷物検査です。中身の分からないスーツケース(unknown)は、検査を通るまでベルトコンベアに乗せられません。

Aパート: JSで書く

新しいファイル src/storage.js を作ります。保存係です。まずは素直に、受け取ったものをそのまま返します。

src/storage.js
const STORAGE_KEY = 'tabi:places';

export function loadPlaces(fallback) {
  const raw = localStorage.getItem(STORAGE_KEY);
  if (raw === null) return fallback;

  const data = JSON.parse(raw);
  return data;
}

export function savePlaces(places) {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(places));
}

書いた関数がやっていること

関数 入力 すること 返すもの
loadPlaces(fallback) 保存が無かったときに使う、既定の行き先 localStorage から文字列を取り出し、JSON.parse で値に戻す 取り出した行き先。保存が無ければ fallback をそのまま
savePlaces(places) いまの行き先の配列 JSON.stringify で文字列にして localStorage に書く なし

この loadPlaces は、取り出したものの形を何ひとつ確かめていません。保存されていたものを、そのまま行き先として信じて返しています。この章の事故は全部そこから起きます。

main.ts に追加・削除のUIを足し、保存を繋ぎます。

src/main.ts+3 / -2
 import './style.css';-import { places } from './places';+import { places as defaultPlaces } from './places';+import type { Place } from './places'; import { setupExchange } from './exchange';+import { loadPlaces, savePlaces } from './storage'; -// 公開するので実際の旅程は書かない。ここはダミーの日付。 const TRIP_DATE = '2026-12-31';

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

src/main.ts+48 / -5
 const placesBox = document.querySelector('#places')!;-placesBox.innerHTML = places-  .map(-    (place) => `+let places: Place[] = loadPlaces(defaultPlaces);++function renderPlaces() {+  placesBox.innerHTML =+    places+      .map(+        (place, index) => `       <article class="place">         <h3>${place.name}</h3>         <p>${place.country} / ${place.nights}泊</p>+        <button class="secondary" data-remove="${index}">削除</button>       </article>     `,-  )-  .join('');+      )+      .join('') ++    `+    <form class="place" id="add-place">+      <input id="new-name" type="text" placeholder="行き先" />+      <input id="new-country" type="text" placeholder="国" />+      <button type="submit">追加</button>+    </form>+  `; +  document.querySelectorAll('[data-remove]').forEach((button) => {+    button.addEventListener('click', () => {+      const index = Number(button.getAttribute('data-remove'));+      places = places.filter((_, i) => i !== index);+      savePlaces(places);+      renderPlaces();+    });+  });++  const form = document.querySelector('#add-place');+  const nameInput = document.querySelector<HTMLInputElement>('#new-name');+  const countryInput = document.querySelector<HTMLInputElement>('#new-country');+  if (form === null || nameInput === null || countryInput === null) return;++  form.addEventListener('submit', (event) => {+    event.preventDefault();+    if (nameInput.value.trim() === '') return;+    places = places.concat({+      name: nameInput.value.trim(),+      country: countryInput.value.trim(),+      nights: 1,+      lat: 35.68,+      lon: 139.77,+      currency: 'JPY',+    });+    savePlaces(places);+    renderPlaces();+  });+}++renderPlaces(); setupExchange();

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

renderPlaces() は、いまの places からカードと追加フォームのHTMLを組み立てて描き直し、削除ボタンと送信の配線をやり直す係です。追加・削除のたびに丸ごと描き直すので、画面と配列が食い違いません。

ここまでがAパートです。行き先を追加・削除でき、リロードしても残るようになりました。

事故を踏む

開発者ツールの Application タブ(Firefoxなら「ストレージ」)を開き、Local Storage の tabi:places を選んで、値を書き換えます。3通り試してください。全部ちがう壊れ方をします。

(1) コース1のメモ形式(文字列の配列)

tabi:places に貼る
["直島","台北","パリ"]

リロードすると、カードは3枚出ますが、中身が undefined / undefined泊 になります。エラーは1つも出ません。静かに壊れるタイプです。

(2) 前のバージョンが書いた形式(配列をオブジェクトで包んでいた)

tabi:places に貼る
{"places":["直島","台北"]}

リロードすると、行き先パネルも、その下の換算パネルも、天気パネルも、まるごと空になります。Consoleを見ると:

Console
Uncaught TypeError: places.map is not a function

リロードしても直りません。壊れたデータが保存されたままだからです。

(3) 途中で切れた壊れたJSON

tabi:places に貼る(わざと閉じ括弧を消す)
["直島","台北"
Console
Uncaught SyntaxError: Expected ',' or ']' after array element in JSON at position 10

こちらもアプリが起動しません。今度は JSON.parse が例外を投げたためです。

3つとも、こちらは何も悪いことをしていません。保存されていたデータの形が想定と違っただけです。
外から来るデータは、いつかこうなります。前のバージョンの自分、別の端末、拡張機能、あるいは手が滑った誰か。だから境界で確かめるのです。

Bパート: TSにする

この章のBパートは、ほかの章と少し違います。いつもは「型を足すだけでロジックは動かさない」のですが、今日は検査そのものを足します。型ガードは型述語(value is Place)というTypeScriptにしか書けない道具で成り立っているからです。
つまり今日のBパートは、「TypeScriptだからできる安全化」そのものです。

まず、検査官を集めたファイル src/guards.ts を新しく作ります(最初から .ts です)。

src/guards.ts
import type { Place } from './places';
import type { Rate } from './rates';

export function isPlace(value: unknown): value is Place {
  return (
    typeof value === 'object' &&
    value !== null &&
    'name' in value &&
    typeof value.name === 'string' &&
    'country' in value &&
    typeof value.country === 'string' &&
    'nights' in value &&
    typeof value.nights === 'number' &&
    'lat' in value &&
    typeof value.lat === 'number' &&
    'lon' in value &&
    typeof value.lon === 'number' &&
    'currency' in value &&
    typeof value.currency === 'string'
  );
}

export function isPlaceArray(value: unknown): value is Place[] {
  return Array.isArray(value) && value.every(isPlace);
}

export function isRate(value: unknown): value is Rate {
  return (
    typeof value === 'object' &&
    value !== null &&
    'date' in value &&
    typeof value.date === 'string' &&
    'quote' in value &&
    typeof value.quote === 'string' &&
    'rate' in value &&
    typeof value.rate === 'number'
  );
}

export function isRateArray(value: unknown): value is Rate[] {
  return Array.isArray(value) && value.every(isRate);
}

検査官がやっていること

関数 入力 すること 返すもの
isPlace(value) 中身の分からない値を1つ オブジェクトか、6つの名札があるか、それぞれの型が合っているかを順に確かめる 合っていれば true。しかも true のとき「これは Place です」と検査官にも伝わる
isPlaceArray(value) 中身の分からない値を1つ 配列かどうかを見て、中身を1つずつ isPlace に通す 全部通れば true
isRate(value) 中身の分からない値を1つ 日付・通貨コード・レートの3つを、isPlace と同じやり方で確かめる 合っていれば true
isRateArray(value) 中身の分からない値を1つ 配列かどうかを見て、中身を1つずつ isRate に通す 全部通れば true

4人とも、返すのは true か false だけです。難しいのは中身ではなく、戻り値の型に書いた value is Place のほうです。1人ぶん読めれば、残りの3人は同じ形です。

次に storage.js を F2 で storage.ts にリネームし、検査を通してから返すように直します。

storage.js → storage.ts型だけの変更+15 / -3
+import type { Place } from './places';+import { isPlaceArray } from './guards';+ const STORAGE_KEY = 'tabi:places'; -export function loadPlaces(fallback) {+export function loadPlaces(fallback: Place[]): Place[] {   const raw = localStorage.getItem(STORAGE_KEY);   if (raw === null) return fallback; -  const data = JSON.parse(raw);+  let data: unknown;+  try {+    data = JSON.parse(raw);+  } catch {+    return fallback;+  }++  if (!isPlaceArray(data)) {+    return fallback;+  }   return data; } -export function savePlaces(places) {+export function savePlaces(places: Place[]): void {   localStorage.setItem(STORAGE_KEY, JSON.stringify(places)); }

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

変わったのは2つです。

  1. JSON.parse を try で囲んだ —— 壊れたJSONで例外が飛んでも、既定値に落ちるようにしました。さっきの(3)の対策です。
  2. isPlaceArray を通した —— 形が違えば既定値に落ちます。(1)と(2)の対策です。

そして rates.ts の as も、同じやり方に置き換えます。

src/rates.ts+2 / -0
+import { isRateArray } from './guards';+ export type Rate = {   date: string;

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

src/rates.ts+4 / -1
   const url = `${ENDPOINT}?base=JPY&quotes=${quotes.join(',')}`;   const res = await fetch(url);-  const data = (await res.json()) as Rate[];+  const data: unknown = await res.json();+  if (!isRateArray(data)) {+    throw new Error('為替APIの返事が想定と違います');+  }   return data; }

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

さあ、さっきの3つをもう一度試してください。 どれを貼ってリロードしても、画面は落ちず、初期状態の3件に戻ります。検査を通らなかったので、保存データを捨てて既定値を使ったのです。

境界では、形だけでなく「例外」も想定する。今日足した try と型ガードは、その2つに対応しています。片方だけでは(3)か(1)(2)のどちらかで落ちます。

as の卒業式

第5章で仮免許を渡した as を、ここで卒業します。なぜ危ないのかを、実話で見てください。

第5章のコラムで触れたとおり、Frankfurterは v1からv2でレスポンスの形が変わりました。v1は入れ子、v2は平らな配列です。もしv1の形を想定した型に as していたら、どうなるでしょう。

v1を想定した型に as した場合
type V1 = { rates: { USD: number } };
const data = (await res.json()) as V1; // 実際に届くのは配列
console.log(data.rates.USD);            // 実行時にクラッシュ

as は赤線を1本も出しません。「これはV1です」と宣言したのだから、TypeScriptは信じます。嘘をついたのは人間のほうです。
型を書いたのに守られなかった——この誤解の正体がこれです。検査していないものは、守られません。

だから unknown で受けて、ガードを通します。今日 rates.ts から as を消したのは、そのためです。このコースには、これ以降 as は1つも出てきません。

ただし、ガードにも限界があります。ガードの中身を書き間違えれば、間違ったまま通ります。そこは人間の責任です。型は、確かめる場所を強制してくれるだけです。

わざと壊してみる

storage.ts の if (!isPlaceArray(data)) のかたまりを消してください。ここで赤くなるのが正解です。 Type 'unknown' is not assignable to type 'Place[]' = 「中身を確かめるまで触れません」。

unknown で受けた時点で、確かめずに返す道が塞がれているのが分かります。any で受けていたら、この赤線は出ませんでした。確認したら戻します。

あなたの番

  • 保存キー(tabi:places)を自分のものに変えてください。
  • 第2章で // mine を付けて足した項目があれば、isPlace にも足してください。 読み替えの約束の実戦です。
  • 開発者ツールで保存データを自分で壊して、ちゃんと初期状態に戻ることを確かめてください。
  • 合格条件: 変えたあとも npm run check がエラー0、そして as が1つも無いこと。

実務メモ

React Nativeの AsyncStorage は今日の localStorage と同じ構図です。実務では手書きのガードではなく zod や valibot といったライブラリで自動化します(種明かしをすると、この教材サイト自身も章データの検査にzodを使っています)。フィールドが3つ4つのうちは手書きで十分です。

TSエラー語辞典

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

エラー英文 言っていること
'data' is of type 'unknown' 中身を確かめるまで触れません
Type 'unknown' is not assignable to type 'Place[]' それが Place[] だという確認がまだです

ハマったら

unknownに触るたび赤線が出る

それで正常です。赤線は「まだ検証していません」という意味です。ルールは1つ、ガードを通すだけです。

ZIPで戻したい

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

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

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

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

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

こうなっていればOK

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

卒業まであと3章です。

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