コース目次 / 第9章
ジェネリクス
天気を足し、fetchを1本に共通化します。ずっと使ってきた山括弧を自分で作ります。
経験者の方へ: JavaScriptに自信がある方は、Aパートは流し読みで構いません。事故が起きるところと、TSにするところから精読してください。
このコースの最難関です(★★)。そして最大のご褒美がある章です。
完全に理解できなくて構いません。fetchJson が使えればOKだと思って進めてください。Aパート(JS版の天気が出た時点)で店じまいしても大丈夫です。
今日のゴール
ダッシュボードが完成形になります。 行き先カードごとに、3日分の天気(最高/最低気温と天気コード)が並びます。
今日つかまえるバグ: 為替のfetchをコピペして天気用に改造したとき、型注釈を1箇所直し忘れる事故。
ミニ解説その1(JS): 並行配列
天気API(Open-Meteo)の返事は、少し変わった形をしています。
{
"daily": {
"time": [
"2026-08-06",
"2026-08-07",
"2026-08-08"
],
"temperature_2m_max": [
31.4,
30.9,
30.8
],
"temperature_2m_min": [
27.8,
28.6,
28.5
],
"weather_code": [
2,
1,
1
]
}
}日付ごとにまとまっているのではなく、項目ごとに配列が並んでいます。これを 並行配列 と呼びます。
time[0] = "2026-08-06" ┐
max[0] = 31.4 ├ この3つで「8月6日」
code[0] = 2 ┘
time[1] = "2026-08-07" ┐
max[1] = 30.9 ├ この3つで「8月7日」
code[1] = 1 ┘だから 番号(index)で突き合わせます。map の第2引数が、ここで役に立ちます。
コピペの痛みを、先に味わう
天気を取る関数を書こうとすると、まず思うのは「為替のfetchをコピーして直せばいい」です。やってみてください。
export async function fetchWeather(lat, lon) {
const url = '...';
const res = await fetch(url);
if (!res.ok) {
throw new Error('通信に失敗しました');
}
const data = await res.json();
if (!isWeather(data)) {
throw new Error('形が違います');
}
return data;
}fetch して、ok を確かめて、json() して、ガードを通す。為替とまったく同じ手順です。違うのはURLとガードだけ。
コピペの何が痛いか。あとで直すとき、両方直さないといけないことです。片方だけ直すと、忘れたほうがしばらく黙って動き続けます。その「黙って」が怖いのです。
Aパート: JSで書く
だから共通部分を1つの関数に抜き出します。新しいファイル src/api.js と src/weather.js を作ります。
export async function fetchJson(url, guard) {
const res = await fetch(url);
if (!res.ok) {
throw new Error(`通信に失敗しました(${res.status})`);
}
const data = await res.json();
if (!guard(data)) {
throw new Error('返ってきたデータの形が想定と違います');
}
return data;
}import { fetchJson } from './api';
export function isWeather(value) {
if (typeof value !== 'object' || value === null || !('daily' in value)) {
return false;
}
const daily = value.daily;
return (
typeof daily === 'object' && daily !== null && 'time' in daily && Array.isArray(daily.time)
);
}
const ENDPOINT = 'https://api.open-meteo.com/v1/forecast';
export async function fetchWeather(lat, lon) {
const params = new URLSearchParams({
latitude: String(lat),
longitude: String(lon),
daily: 'temperature_2m_max,temperature_2m_min,weather_code',
timezone: 'Asia/Tokyo',
forecast_days: '3',
});
return fetchJson(`${ENDPOINT}?${params.toString()}`, isWeather);
}rates.js も fetchJson を使うように直します。行数が減ります。
+import { fetchJson } from './api'; import { isRateArray } from './guards';行頭の + - は変更の目印です。コピーすると、記号と削除された行を除いたこの章を終えた時点の内容が入ります。
export async function fetchRates(quotes: string[]): Promise<Rate[]> { const url = `${ENDPOINT}?base=JPY"es=${quotes.join(',')}`;- const res = await fetch(url);- const data: unknown = await res.json();- if (!isRateArray(data)) {- throw new Error('為替APIの返事が想定と違います');- }- return data;+ return fetchJson(url, isRateArray); }行頭の + - は変更の目印です。コピーすると、記号と削除された行を除いたこの章を終えた時点の内容が入ります。
main.ts に天気の描画を足します。
import { setupExchange } from './exchange'; import { loadPlaces, savePlaces } from './storage';+import { fetchWeather } from './weather'; const TRIP_DATE = '2026-12-31';行頭の + - は変更の目印です。コピーすると、記号と削除された行を除いたこの章を終えた時点の内容が入ります。
renderPlaces(); setupExchange();++const weatherBox = document.querySelector('#weather')!;++async function renderWeather() {+ weatherBox.textContent = '読み込み中…';+ try {+ const cards = await Promise.all(+ places.map(async (place) => {+ const weather = await fetchWeather(place.lat, place.lon);+ const days = weather.daily.time+ .map((day: string, index: number) => {+ const max = weather.daily.temperature_2m_max?.[index];+ 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>`;+ })+ .join('');+ return `<article class="place"><h3>${place.name}</h3><div class="forecast">${days}</div></article>`;+ }),+ );+ weatherBox.innerHTML = cards.join('');+ } catch (error) {+ const message = error instanceof Error ? error.message : '原因が分かりません';+ weatherBox.textContent = `天気を取得できませんでした(${message})`;+ }+}++renderWeather();行頭の + - は変更の目印です。コピーすると、記号と削除された行を除いたこの章を終えた時点の内容が入ります。
ここまでがAパートです。天気が出たら、今日はここで店じまいしてOKです。
ミニ解説その2(TS): ジェネリクス
さて、fetchJson をTypeScriptにするときに困ります。戻り値の型は何と書けばいいでしょう?
為替から呼べば Rate[]、天気から呼べば Weather。呼ぶ場所によって違います。
any と書くと、せっかくのガードが台無しです。ここで登場するのが ジェネリクス です。
export async function fetchJson<T>(
url: string,
guard: (value: unknown) => value is T,
): Promise<T> {<T> は 「型の名前を1つ、あとで決めます」 という宣言です。T は決まっていない箱で、呼ぶときに中身が決まります。
2レーンの地図の最終形:
丸括弧 ( ) は値の世界の引数。山括弧 < > は型の世界の引数。
どちらも「あとから渡すもの」という点で同じです。渡すものが値か型かの違いだけです。
そして伏線の回収です。あなたはこの山括弧を、ずっと前から使ってきました。
| 章 | 書いたもの | 意味 |
|---|---|---|
| 第3章 | querySelector<HTMLInputElement> |
取ってくる部品の型を渡した |
| 第5章 | Promise<Rate[]> |
あとで届くものの型を渡した |
| 第8章 | FetchState の中の Rate[] |
積み荷の型を書いた |
| 第9章 | fetchJson<T> |
自分で受け取る側を作る |
ずっと使ってきたあれを、今日は自分で作ります。
しかも、たいていの場合は 書かなくても推論されます。fetchJson(url, isRateArray) と書けば、ガードの型から T = Rate[] だとTypeScriptが自分で気づきます。
Bパート: TSにする
api.js と weather.js を F2 でそれぞれ .ts にします。
-export async function fetchJson(url, guard) {+export async function fetchJson<T>(+ url: string,+ guard: (value: unknown) => value is T,+): Promise<T> { const res = await fetch(url); if (!res.ok) { throw new Error(`通信に失敗しました(${res.status})`); } - const data = await res.json();+ const data: unknown = await res.json(); if (!guard(data)) { throw new Error('返ってきたデータの形が想定と違います'); } return data; }行頭の + - は変更の目印です。コピーすると、記号と削除された行を除いたこの章を終えた時点の内容が入ります。
import { fetchJson } from './api'; -export function isWeather(value) {+export type Weather = {+ daily: {+ time: string[];+ temperature_2m_max?: number[];+ temperature_2m_min?: number[];+ weather_code?: number[];+ };+};++export function isWeather(value: unknown): value is Weather { if (typeof value !== 'object' || value === null || !('daily' in value)) { return false; } const daily = value.daily;- return (- typeof daily === 'object' && daily !== null && 'time' in daily && Array.isArray(daily.time)- );+ return typeof daily === 'object' && daily !== null && 'time' in daily && Array.isArray(daily.time); } const ENDPOINT = 'https://api.open-meteo.com/v1/forecast'; -export async function fetchWeather(lat, lon) {+export async function fetchWeather(lat: number, lon: number): Promise<Weather> { const params = new URLSearchParams({ latitude: String(lat), longitude: String(lon), daily: 'temperature_2m_max,temperature_2m_min,weather_code', timezone: 'Asia/Tokyo', forecast_days: '3', }); return fetchJson(`${ENDPOINT}?${params.toString()}`, isWeather); }行頭の + - は変更の目印です。コピーすると、記号と削除された行を除いたこの章を終えた時点の内容が入ります。
optional プロパティ ?
Weather 型の temperature_2m_max?: number[] の ? に注目してください。「あるかもしれないし、無いかもしれない」という意味です。
なぜ ? が要るのか。Open-Meteoは こちらが頼んだ変数しか返さない からです。URLの daily= から weather_code を外せば、返事にも weather_code は入りません。実物がそうなっている以上、型もそう書くのが正直です。
だから使うときは weather.daily.temperature_2m_max?.[index] のように、?. で「無ければ undefined」と受けます。
わざと壊してみる
rates.ts の fetchJson の呼び出しに、わざと違う型引数を書いてください。
return fetchJson<string[]>(url, isRateArray);ここで赤くなるのが正解です。 ガードは Rate[] を保証するのに string[] だと宣言したので、食い違いが指摘されます。コピペ改造で型を直し忘れる事故が、構造的に起きなくなりました。
長いエラーメッセージが出たときのコツ: 最後の行から読んでください。いちばん具体的な原因が最後に書いてあることが多いです。
あなたの番
dailyの変数を1つ足してください。 例えば降水確率(precipitation_probability_max)。URLのパラメータ・型・画面表示の3点セットを自分で直します。このコースで最高の実戦演習です。- 発展(余裕があれば): ジオコーディングAPIで行き先を名前から検索できます。
https://geocoding-api.open-meteo.com/v1/search?name=Naoshima&count=3&language=ja発展課題の注意: 見つからなかったとき、このAPIは results というキー自体を返しません(空配列ではありません)。だから型は results?: ... になります。optionalが必要になる実物の教材です。
- 合格条件: 変えたあとも
npm run checkがエラー0。
実務メモ
この api.ts は、コピーしてそのまま自分のReact Nativeアプリに持っていって構いません。 持ち帰り資産その1です。
TSエラー語辞典
この章で実際に出るエラー英文と、その日本語訳です。卒業するころには全章ぶんがお助けページに集まります。
| エラー英文 | 言っていること |
|---|---|
Expected 1 type arguments, but got 0 |
山括弧の空欄を埋めてください(または推論に任せてください) |
Object is possibly 'undefined' |
それは無いかもしれません。?. で受けてください |
ハマったら
並行配列の番号がずれる
time[i] と temperature_2m_max[i] の i は必ず同じにしてください。別の変数を使うと、日付と気温が1日ずれます。
APIに繋がらない(5分試してダメなら)
第5章と同じく、録画済みのデータに差し替えれば進めます。第5章で作った fixtures フォルダに、weather.json を足してください。
{
"latitude": 34.45,
"longitude": 134.0,
"generationtime_ms": 0.08380413055419922,
"utc_offset_seconds": 32400,
"timezone": "Asia/Tokyo",
"timezone_abbreviation": "GMT+9",
"elevation": 5.0,
"daily_units": {
"time": "iso8601",
"temperature_2m_max": "\u00b0C",
"temperature_2m_min": "\u00b0C",
"weather_code": "wmo code"
},
"daily": {
"time": [
"2026-08-06",
"2026-08-07",
"2026-08-08"
],
"temperature_2m_max": [
31.4,
30.9,
30.8
],
"temperature_2m_min": [
27.8,
28.6,
28.5
],
"weather_code": [
2,
1,
1
]
}
}import sample from '../fixtures/weather.json';
export async function fetchWeather(lat, lon) {
return sample;
}ZIPで戻したい
このページの下には2つのZIPがあります。JS版はAパート(JSで動いた状態)まで、TS版は章末(TS化まで終わった状態)です。詰まった場所に合わせて選んでください。
解凍したら、フォルダの中で次の2つを順に打ちます。
行頭の $ は「ここからコマンド」という目印です。$ は打たないでください。
丸ごと置き換えると自分で書き換えた内容が消えます。 まず src/places.ts(自分の行き先データ)と、自分で変えた文言をメモ帳に待避してから、壊れたファイルだけを差し替えてください。
こうなっていればOK
うまくいかないときは、この章のファイル一式をダウンロードして続きから進めても大丈夫です。JS版(TS化する前)とTS版(章末)の2つがあります。
卒業まであと1章です。
この章はまだ完了していません。