コース目次 / 第10章
ユーティリティ型とtsconfig
Record/Pick/Partialで重複を消し、tsconfigを1行ずつ読んで卒業します。
最後の章です。ここは下山です。新しく難しいものは出てきません。いままで使ってきた道具の意味を回収して、荷物をまとめて帰ります。
今日のゴール
天気コードの数字が 日本語ラベル+絵文字 になります。そして型チェック付きのビルドで公開しなおして、卒業です。
今日つかまえるバグ: 対応表に載っていない天気コードが来た日に、ラベルが undefined になる事故。そして、これは型では防げません。
ミニ解説(TS): Record
「数字 → 文字列」の対応表を作る型が Record です。
const WEATHER_LABELS: Record<number, string> = {
0: '快晴 📸',
1: 'おおむね晴れ 🌤',
};Record<キーの型, 値の型> です。これも山括弧、つまりジェネリクスです。第9章で自分で作ったので、もう読めます。
Aパート: JSで書く
新しいファイルを2つ作ります。src/labels.js(天気コードの対応表)と src/dom.js(! を卒業するための道具)です。
export function mustFind(selector) {
const found = document.querySelector(selector);
if (found === null) {
throw new Error(`要素が見つかりません: ${selector}`);
}
return found;
}const WEATHER_LABELS = {
0: '快晴 📸',
1: 'おおむね晴れ 🌤',
2: '薄曇り ⛅',
3: '曇り ☁️',
45: '霧 🌫',
51: '霧雨 🌦',
61: '小雨 🌧',
63: '雨 🌧',
65: '強い雨 🌧',
71: '雪 ❄️',
80: 'にわか雨 🌦',
95: '雷雨 ⛈',
};
export function weatherLabel(code) {
if (code === undefined) return '不明';
return WEATHER_LABELS[code] ?? '不明';
}main.ts と exchange.ts から ! を消して mustFind に置き換え、天気にラベルを付けます。
import { loadPlaces, savePlaces } from './storage'; import { fetchWeather } from './weather';+import { weatherLabel } from './labels';+import { mustFind } from './dom'; const TRIP_DATE = '2026-12-31';行頭の + - は変更の目印です。コピーすると、記号と削除された行を除いたこの章を終えた時点の内容が入ります。
} -const greeting = document.querySelector('#greeting')!;+const greeting = mustFind('#greeting'); greeting.textContent = greet('はると', daysUntil(TRIP_DATE)); -const placesBox = document.querySelector('#places')!;+const placesBox = mustFind('#places'); let places: Place[] = loadPlaces(defaultPlaces);行頭の + - は変更の目印です。コピーすると、記号と削除された行を除いたこの章を終えた時点の内容が入ります。
setupExchange(); -const weatherBox = document.querySelector('#weather')!;+const weatherBox = mustFind('#weather'); async function renderWeather() {行頭の + - は変更の目印です。コピーすると、記号と削除された行を除いたこの章を終えた時点の内容が入ります。
const min = weather.daily.temperature_2m_min?.[index]; const code = weather.daily.weather_code?.[index];- return `<span class="day">${day.slice(5)} ${min}〜${max}℃ (コード${code})</span>`;+ return `<span class="day">${day.slice(5)} ${min}〜${max}℃ ${weatherLabel(code)}</span>`; }) .join('');行頭の + - は変更の目印です。コピーすると、記号と削除された行を除いたこの章を終えた時点の内容が入ります。
import type { Rate } from './rates'; import { showState } from './state';+import { mustFind } from './dom'; const CURRENCIES = ['TWD', 'EUR', 'USD'];行頭の + - は変更の目印です。コピーすると、記号と削除された行を除いたこの章を終えた時点の内容が入ります。
export async function setupExchange(): Promise<void> {- const box = document.querySelector('#exchange')!;+ const box = mustFind('#exchange'); box.innerHTML = ` <label class="field">行頭の + - は変更の目印です。コピーすると、記号と削除された行を除いたこの章を終えた時点の内容が入ります。
ここまでがAパートです。
! の卒業式
第1章で渡した仮免許を、ここで返します。
! は「絶対ある、と私が保証します」でした。でも保証したのは人間で、間違っていても誰も教えてくれません。
mustFind は本当に確かめて、無ければその場で分かるように落とします。真っ白な画面で悩むかわりに、Consoleに「#greeting が見つかりません」と出ます。
これが「仮免許を返す」ということです。
プロジェクト全体を ! で検索して、1つも残っていないことを確かめてください。
Bパート: TSにする
labels.js と dom.js を F2 でそれぞれ .ts にします。
-const WEATHER_LABELS = {+const WEATHER_LABELS: Record<number, string> = { 0: '快晴 📸', 1: 'おおむね晴れ 🌤', 2: '薄曇り ⛅', 3: '曇り ☁️', 45: '霧 🌫', 51: '霧雨 🌦', 61: '小雨 🌧', 63: '雨 🌧', 65: '強い雨 🌧', 71: '雪 ❄️', 80: 'にわか雨 🌦', 95: '雷雨 ⛈', }; -export function weatherLabel(code) {+export function weatherLabel(code: number | undefined): string { if (code === undefined) return '不明'; return WEATHER_LABELS[code] ?? '不明'; }行頭の + - は変更の目印です。コピーすると、記号と削除された行を除いたこの章を終えた時点の内容が入ります。
-export function mustFind(selector) {- const found = document.querySelector(selector);+export function mustFind<T extends Element>(selector: string): T {+ const found = document.querySelector<T>(selector); if (found === null) { throw new Error(`要素が見つかりません: ${selector}`); } return found; }行頭の + - は変更の目印です。コピーすると、記号と削除された行を除いたこの章を終えた時点の内容が入ります。
Record の限界(このコースの最後の教え)
WEATHER_LABELS[code] の型は string です。string | undefined ではありません。
でも実際には、対応表に無いコードの日があります(天気コードは100種類近くあり、全部は書いていません)。そのとき返るのは undefined です。
型は「無いかもしれない」を教えてくれませんでした。だから ?? '不明' を自分で書いて受け止めます。
型は現実のすべてを表せません。これはTypeScriptの欠陥ではなく、道具の輪郭です。最後は実行時の備えと組み合わせる——それがこのコースの締めくくりです。
Pick で重複を消す
storage.ts に、保存用の型を足します。
import { isPlaceArray } from './guards'; +export type SavedPlace = Pick<Place, 'name' | 'lat' | 'lon' | 'currency'>;+ const STORAGE_KEY = 'tabi:places';行頭の + - は変更の目印です。コピーすると、記号と削除された行を除いたこの章を終えた時点の内容が入ります。
Pick<Place, 'name' | 'lat' | 'lon' | 'currency'> は「Place から、この4つだけ抜き出した型」です。同じ形をもう一度書かずに済みます。仲間に Omit(除く)、Partial(全部を省略可にする)、ReturnType(関数の戻り値の型)があります。名前だけ知っておけば、必要になったときに調べられます。
interface の正式読解
第2章でコラムとして見せた interface を、ここで正式に読みます。
type |
interface |
|
|---|---|---|
| オブジェクトの形 | 書ける | 書ける |
ユニオン(A | B) |
書ける | 書けない |
| あとから項目を足す | できない | できる(宣言のマージ) |
このコースが type に統一したのは、ユニオンが書けるからでした。世の中のコードやライブラリの型定義には interface が大量に出てきますが、読むぶんには「オブジェクトの形の宣言」と思って差し支えありません。
tsconfig を1行ずつ読む
第1章では3行だけ読みました。いま全部読めます。それぞれが、あなたが実際に踏んだバグに対応しています。
| 行 | 何をしていたか |
|---|---|
"target": "ES2022" |
出力するJavaScriptの世代 |
"module" / "moduleResolution": "bundler" |
拡張子なしで import できた種明かし。 これのおかげで、リネームしても呼び出し側を1行も直さずに済みました |
"strict": true |
possibly 'null'(第3章)、catch が unknown(第8章)など、このコースの守りの大半 |
"allowJs": true |
毎章のAパートを支えていた行。 .js が混ざっていても check が緑だったのはこれのおかげです |
"noEmit": true |
検査だけして、ファイルは出力しない(出力はViteの仕事) |
"include": ["src"] |
見に行く範囲 |
実験1: allowJs を外してみる
もう全ファイルが .ts になったので、外しても緑のままのはずです。 "allowJs": true の行を消して npm run check を打ってみてください。エラーが出なければ、役目を終えたことの証明です(確認したら戻しておきましょう)。
実験2: strict を false にしてみる(恐怖体験)
"strict": true を false にして npm run check を打ってください。赤線が全部消えます。
消えたのはエラーではありません。バグの検出です。
コードは1文字も直っていないのに、警告だけが消えました。すぐ true に戻してください。
「strictを外せば楽になる」は、「健康診断を受けなければ健康」と同じ理屈です。
実験3: noUncheckedIndexedAccess
"strict": true の下に "noUncheckedIndexedAccess": true を足して check してみてください。配列やRecordの添字アクセスが | undefined になり、いままで見えていなかった穴が1つ2つ見つかります。1つだけ直してみて、そのあとは好みで残すか外すか決めてください。
ビルドを強化する
ここまで npm run build は型チェックをしていませんでした(第6章の正直な注記)。直します。
"dev": "vite", "check": "tsc --noEmit",- "build": "vite build",+ "build": "tsc --noEmit && vite build", "preview": "vite preview" },行頭の + - は変更の目印です。コピーすると、記号と削除された行を除いたこの章を終えた時点の内容が入ります。
行頭の $ は「ここからコマンド」という目印です。$ は打たないでください。
わざと型エラーを入れてビルドを止める
main.ts のどこかに const x: number = 'あ'; と書いて、もう一度 npm run build を打ってください。
ビルドが止まります。 画面では動いていたのに、出荷は止まりました。
これがCI(自動チェック)の守りです。「動いているように見えるけれど出荷してはいけないもの」を、機械が止めてくれます。確認したら消してビルドし直してください。
公開②
第6章と同じ手順で、dist を 自分のサイトの Deploys タブ にドロップします。
公開前の安全チェックをもう一度。 旅の日付・行き先は実物になっていませんか。ダミーのままですか。
あなたの番
- ラベルと絵文字を自分の言葉にしてください。見本ははるとのカメラ趣味に寄せて、快晴を
📸にしています。 - OGPの文言(コース1第9章の復習)を足してもよいでしょう。
- 合格条件:
npm run buildが通り、公開URLが最新になっていること。
卒業の前に — 持ち帰るもの
api.tsのfetchJson<T>+ ガード — 通信の入口の雛形FetchState判別可能ユニオン — 通信UIの状態設計unknown→ 型ガード — 外から来るデータの受け止め方- 最小の tsconfig の読み方 — 新しいプロジェクトを立てるとき
この4つは、明日から自分のプロジェクトにそのまま持っていけます。
次にやること(3択)
- コース3の予告 — フレームワーク(と、ついにGit)へ
- 自由改造アイデア — 週間予報のグラフ / 現地時刻の表示 / お気に入りの並べ替え / 為替の履歴グラフ
- 型定義の歩き方 —
lib.dom.d.tsを覗いてみる(querySelectorにジャンプすると、TypeScript自身の型定義が読めます)/ zod の入口
実務メモ
実務では CI で tsc --noEmit を回します。今日 build に足したのと同じことを、pull request のたびに機械がやってくれる構成です。React Nativeのプロジェクトの tsconfig.json を開くと "extends": "expo/tsconfig.base" と書いてあります。土台を継承して、自分の分だけ書く——今日読んだ知識で、その中身も追えます。
TSエラー語辞典
この章で実際に出るエラー英文と、その日本語訳です。卒業するころには全章ぶんがお助けページに集まります。
| エラー英文 | 言っていること |
|---|---|
Object is possibly 'undefined' |
それは無いかもしれません(noUncheckedIndexedAccess実験の主役) |
Type 'string' is not assignable to type 'number' |
そこは数値の席です(ビルド停止体験の主役) |
ハマったら
tsconfigをいじったら全部赤くなった
戻せば戻ります。 1行ずつ変えて、確かめて、戻す。それがこの章の実験の作法です。分からなくなったらZIPの tsconfig.json で置き換えてください。
ビルドのときだけエラーが出た
npm run check をコースの間ずっと回してきたので、原則ここでは出ません。出た場合は check と同じ読み方(上から1個ずつ、ファイル名と行番号だけ見る)で潰せます。
ZIPで戻したい
このページの下には2つのZIPがあります。JS版はAパート(JSで動いた状態)まで、TS版は章末(TS化まで終わった状態)です。詰まった場所に合わせて選んでください。
解凍したら、フォルダの中で次の2つを順に打ちます。
行頭の $ は「ここからコマンド」という目印です。$ は打たないでください。
丸ごと置き換えると自分で書き換えた内容が消えます。 まず src/places.ts(自分の行き先データ)と、自分で変えた文言をメモ帳に待避してから、壊れたファイルだけを差し替えてください。
こうなっていればOK
うまくいかないときは、この章のファイル一式をダウンロードして続きから進めても大丈夫です。JS版(TS化する前)とTS版(章末)の2つがあります。
この章で卒業です。
この章はまだ完了していません。