Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

200 OK でも安全ではない、Server Component を壊した型アサーションの穴

公開 読了時間 約6分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

HTTPステータスが200でJSONパースにも成功しても、res.json() as Tという型アサーションは実行時にレスポンスの形を検証しないため、契約外のJSONがそのまま下流のServer Componentへ渡ってクラッシュを起こす。

結論

HTTPステータスが200でJSONパースにも成功しても、レスポンスの形が契約通りとは限らない。(await res.json()) as Tという型アサーションはコンパイラに形を信じ込ませるだけで、実行時には何も検証しない。必須フィールドが欠けた200応答をそのまま下流に渡すと、それを前提に組まれたReact Server Componentがクラッシュする。

症状

kimiteras-portalには、v2 APIから月次の効果メトリクス(表示回数・タップ数・滞在秒数など)を取得するfetchV2Metricsという関数がある(src/lib/v2-metrics.ts)。このファイルの冒頭には、次の不変条件がコメントとして明記されている。

フォールバック契約: いかなる失敗(未設定・未紐付け・401/404/422/5xx・ネットワーク断)でも
throw せず { ok:false, reason, status? } を返す。呼び出し側(レポート画面)は
これを受けて手動 ad_metrics/「未紐付け」表示に安全にフォールバックする。

実装も、ネットワークエラー・JSONパースエラー・401/404・その他のHTTPエラーのそれぞれに対して、この契約どおりtry/catchと条件分岐で{ ok: false, reason }を返すよう作られていた。

ただし、res.okが真でJSONのパースにも成功した経路だけは、レスポンスの中身を検証せずにそのまま{ ok: true, data }として返していた。

if (res.ok) {
  try {
    const data = (await res.json()) as V2Metrics;
    return { ok: true, data };
  } catch (e) {
    return {
      ok: false,
      reason: `parse_error: ${e instanceof Error ? e.message : String(e)}`,
    };
  }
}

呼び出し側のV2MetricsPanel(Server Component)は、このdataからtotalsを取り出し、impressions(表示回数)やdwell_seconds(滞在秒数)を直接参照して画面に描画する。

export default function V2MetricsPanel({ data }: { data: V2Metrics }) {
  const t = data.totals;
  const isLive = data.source === "live";

  const secondary = [
    { label: "露出(のべ表示)", value: num(t.impressions) },
    { label: "QR・リンク", value: num(t.taps) },
    { label: "滞在(のべ)", value: formatDwell(t.dwell_seconds) },
    { label: "質問", value: num(t.asks) },
  ];

v2 API側がtotalsを含まない200レスポンスを返した場合、data.totalsundefinedになり、その先のt.impressionsで例外が発生してコンポーネントごとクラッシュする。この経路はコードレビューで指摘され、実際にユーザーの画面が落ちる前に見つかって修正された。

原因

TypeScriptの型定義では、V2Metricstotalsは必須フィールドとして宣言されている(オプショナルではない)。

export type V2Metrics = {
  advertiser_id: string;
  company_name: string;
  period: string; // "YYYY-MM"
  tz: string;
  totals: V2MetricsTotals;
  by_school?: V2MetricsSchoolRow[];
  contracts?: V2MetricsContract[];
  generated_at: string;
  source: "monthly_reports" | "live" | string; // 確定 | 速報
};

しかしfetchV2Metricsの実装は、v2から返ってきたJSONを(await res.json()) as V2Metricsという型アサーションで済ませていた。asはコンパイラに対する宣言であって、実行時の検証ではない。v2 APIが実際にtotalsを含まない200レスポンスを返してきても、TypeScriptはコンパイル時にそれを検出できず、dataは「V2Metrics型である」という前提のまま呼び出し元に渡ってしまう。

つまりこの関数は、自分自身のコメントに「いかなる失敗でもthrowしない」という契約を明記していながら、**一番信頼してよさそうに見える200という経路でだけ、その契約の対象から外れていた。**ネットワークエラーやJSONパースエラーにはtry/catchで対応していたのに、「200だがJSONの形が契約と違う」という契約ドリフトだけがノーガードのまま残っていた。

直し方

res.okかつJSONパースにも成功した経路に、totalsの形を検証するガードを追加した。

if (res.ok) {
  let data: V2Metrics;
  try {
    data = (await res.json()) as V2Metrics;
  } catch (e) {
    return {
      ok: false,
      reason: `parse_error: ${e instanceof Error ? e.message : String(e)}`,
    };
  }
  // 200 でも契約外の形(totals 欠落)なら、画面(V2MetricsPanel が totals を参照)を
  // 壊さずフォールバックへ回す(契約ドリフト保険・throw しない契約を全 200 経路で担保)。
  if (!data || typeof data.totals !== "object" || data.totals === null) {
    return { ok: false, reason: "parse_error: missing totals" };
  }
  return { ok: true, data };
}

typeof data.totals !== "object"またはnullなら、成功扱いにせず{ ok: false, reason: "parse_error: missing totals" }としてフォールバックへ回す。これで「200なら型どおり」という前提を型アサーションだけに委ねるのをやめ、実行時にも検証するようになった。呼び出し元の画面は、他の失敗パターンと同じく手動ad_metrics表示へ安全にフォールバックする。

再発防止

この修正では、ガードを足すだけでなく、totalsが欠けた200レスポンスを模したテストケースを追加して回帰を固定している。

it("200 だが契約外の形(totals 欠落)→ parse_error(画面を壊さずフォールバック)", async () => {
  vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
    jsonResponse({ advertiser_id: ADV, period: YM }) // totals 無し
  );
  const r = await fetchV2Metrics(ADV, YM);
  expect(r.ok).toBe(false);
  if (r.ok) throw new Error();
  expect(r.reason).toMatch(/missing totals/);
});

as Tによる型アサーションだけで外部APIのレスポンスを受け取っている箇所は、この関数以外にも存在しうる。HTTPステータスとres.okだけを見て「200だから安全」と判断している経路は、実際にはレスポンスの形まで検証していないという前提で見直す価値がある。

よくある質問

Q1なぜTypeScriptの型があるのにクラッシュを防げなかったのですか?

as V2Metricsという型アサーションはコンパイラに『この形だと信じろ』と伝えるだけで、実行時にはJSONの中身を検証しません。外部APIが実際にtotalsを含まない200レスポンスを返しても、TypeScriptはそれを検出できず、コンパイルも普通に通ります。

Q2HTTPステータスが200なら安全だと考えてはいけないのですか?

このケースでは200かつJSONパース成功でも、外部API側の実装や仕様変更次第で契約外の形(必須フィールド欠落)が返ってくることがあります。ステータスコードはレスポンスの中身の正しさまでは保証しません。

Q3この種の契約ドリフトはどう防げますか?

res.okだけで成功と判断せず、必須フィールドの型と有無を実行時に検証してから成功扱いにすることです。この記事の修正ではtypeof data.totals !== objectのガードを200経路に追加し、欠落時はフォールバックへ回すテストケースも追加しました。

確認した環境

  • Next.js 16.2.7 / React 19.2.4 / TypeScript ^5 — kimiteras-portal
  • 2026-06-10導入・同日のコードレビューで発覚し修正

この記事の根拠

  • TypeScriptファイル 1〜16行目コミット 19c214e
  • TypeScriptファイル 42〜52行目コミット 19c214e
  • TypeScriptファイル 127〜137行目コミット 19c214e
  • TypeScriptファイル 20〜29行目コミット 19c214e
  • TypeScriptファイル 127〜142行目コミット 9236e0c
  • TypeScriptファイル 208〜216行目コミット 9236e0c

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。