ある朝、私が個人開発しているアプリの一つで、特定の一覧画面だけが真っ白になっているという報告が届きました。クラッシュログには何も出ていません。落ちてはいないのです。ただ、データが表示されないだけでした。
原因はサーバー側にありました。前日にAPIのレスポンスで price フィールドが数値から文字列に変わっていたのです。Rork が生成していた fetch コードは、そのフィールドをそのまま数値として扱っていました。型の食い違いは実行時まで誰にも気づかれず、画面の描画だけが静かに止まっていました。
生成されたネットワークコードのこの「静かな壊れ方」は、個人開発で複数のアプリを回していると避けて通れない問題です。私自身、App Store と Google Play に出しているアプリで、同じ轍を何度も踏んできました。生成コードを全部書き直すのではなく、ネットワークとUIの間に検証の境界を1枚だけ挟む。ここでまとめるのは、その最小限のアプローチです。
生成された fetch コードは、なぜ静かに壊れるのか
Rork のようなAIビルダーが fetch コードを書くとき、レスポンスの形はプロンプトや1つのサンプルから推論されます。生成されるのは、たいてい次のようなコードです。
// Rork が生成しがちな典型的な fetch
async function getProducts () {
const res = await fetch ( "https://api.example.com/products" );
const data = await res. json ();
// data.items は配列である「はず」— でも保証はどこにもない
return data.items. map (( item ) => ({
id: item.id,
name: item.name,
price: item.price, // number である「はず」
}));
}
このコードは、生成された瞬間は正しく動きます。問題は、レスポンスの形が「その瞬間の想定」に固定されていることです。
APIが items を results に改名したら、data.items.map は undefined.map になって例外を投げます。price が文字列になったら、例外は投げずに、金額計算が NaN になったり、比較がおかしくなったりします。後者のほうが厄介です。落ちてくれるバグは気づけますが、静かに間違うバグは本番で長く生き残ります。
TypeScript の型注釈は、この食い違いを守ってくれません。型は開発時の約束であって、実行時にサーバーから届くJSONを検査してはくれないからです。境界の外から来るデータは、いつでも型を裏切ります。
検証境界を1枚だけ挟むという考え方
対策として「生成コードを信用せず全部手で書き直す」という選択もあります。ですが、それは Rork を使う意味を薄めてしまいます。私が3ヶ月ほど使ってきた実感として、足場(画面の骨格やレイアウト)はAIに任せ、要所だけ自分で通す、という分担が現実的でした。
その「要所」の一つが、ネットワークとUIの境界です。
考え方はシンプルです。生成された fetch とUIコンポーネントの間に、レスポンスを検査して信頼できる形に変換する層を1枚だけ挟みます。この層を通り抜けたデータは、以降のコードが安心して型どおりに扱えます。層の内側(サーバーとの通信)で何が起きても、UIには「検査済みのデータ」か「扱えるエラー」のどちらかしか流れません。
この境界を実装する道具として、実行時にスキーマを検証できる Zod がよく合います。フォームの入力検証で Zod を使ったことがある方は多いと思いますが、同じ発想をAPIレスポンスの側にも向けるわけです。入力側の検証についてはRork が生成したフォーム画面を react-hook-form と zod で立て直す方法 で扱っています。この記事はその逆方向、サーバーから来るデータの側の話です。
Zod スキーマを「レスポンスの契約」として書く
まず、APIが返すべき形をスキーマとして1箇所に定義します。これがUIとサーバーの間で交わす契約になります。
// schemas/product.ts — レスポンスの契約を1箇所に集約する
import { z } from "zod" ;
export const ProductSchema = z. object ({
id: z. string (),
name: z. string (),
// z.coerce.number() で "1200" のような文字列も数値に寄せる
// サーバーが型を揺らしても、UIには常に number が届く
price: z.coerce. number (),
});
export const ProductListSchema = z. object ({
items: z. array (ProductSchema),
});
// スキーマから型を導出する — 手書きの interface と二重管理しない
export type Product = z . infer < typeof ProductSchema>;
ここで z.infer を使うと、スキーマ1つから型が導出され、手書きの interface と二重に管理する必要がなくなります。契約が1箇所にまとまることが、後の保守で効いてきます。
z.coerce.number() は、公式ドキュメントでは数値への型強制として説明されていますが、実運用ではサーバー側の型の揺れを吸収するクッションとして役立ちます。price が 1200 でも "1200" でも、UIには常に number が届きます。ただし後述するとおり、何でも coerce すればよいわけではありません。
生成された fetch を壊さずに境界を差し込む
契約ができたら、生成された fetch を最小限だけ書き換えます。ロジックを大きく変えるのではなく、レスポンスをスキーマに通す1行を足すのが基本です。
// api/products.ts — 生成コードに parse の境界を1枚足す
import { ProductListSchema, type Product } from "../schemas/product" ;
export async function getProducts () : Promise < Product []> {
const res = await fetch ( "https://api.example.com/products" );
if ( ! res.ok) {
throw new ApiError ( `products fetch failed: ${ res . status }` );
}
const json = await res. json ();
// ここが境界 — 検査を通ったデータだけが先へ進む
const parsed = ProductListSchema. parse (json);
return parsed.items;
}
差分はごくわずかです。data.items.map(...) を直接触る代わりに、ProductListSchema.parse(json) を1回通すだけです。
この一手で挙動が根本的に変わります。もし items が消えたり price が想定外の形になったら、parse はその場で例外を投げます。壊れる場所が「UIの描画中の奥深く」から「ネットワーク境界のこの1行」に移動します。原因の特定にかかる時間が、体感で大きく縮みます。
「静かな空白」を「扱えるエラー」に変える
境界で parse を使うと例外が飛びます。ですが、UI層で予期しない例外が飛ぶと、それはそれで画面が壊れます。そこで、UIに近い場所では safeParse を使い、成否を値として受け取る形にします。
// hooks/useProducts.ts — safeParse で成否を状態として扱う
import { useState, useEffect } from "react" ;
import { ProductListSchema, type Product } from "../schemas/product" ;
type Result =
| { status : "loading" }
| { status : "ready" ; products : Product [] }
| { status : "error" ; reason : "network" | "shape" };
export function useProducts () : Result {
const [ result , setResult ] = useState < Result >({ status: "loading" });
useEffect (() => {
let alive = true ;
( async () => {
try {
const res = await fetch ( "https://api.example.com/products" );
const json = await res. json ();
const parsed = ProductListSchema. safeParse (json);
if ( ! alive) return ;
if ( ! parsed.success) {
// 形が契約と違う — ここで初めて気づける
logShapeMismatch (parsed.error);
setResult ({ status: "error" , reason: "shape" });
return ;
}
setResult ({ status: "ready" , products: parsed.data.items });
} catch {
if (alive) setResult ({ status: "error" , reason: "network" });
}
})();
return () => {
alive = false ;
};
}, []);
return result;
}
ポイントは、reason で「通信の失敗」と「形の違い」を区別していることです。この2つは原因も対処もまったく別物です。通信失敗は再試行で直ることが多い一方、形の違いはコード側かサーバー側の修正が必要です。UIで別々のメッセージを出せますし、何より運用中に両者を切り分けられます。
logShapeMismatch では、parsed.error が「どのフィールドが契約と違ったか」を教えてくれます。これをログに残しておくと、APIの変化に事後ではなく即座に気づけます。ログに個人情報を残さない設計についてはRork アプリのログ設計 — 何を残し、個人情報をどう伏せるか を参照してください。落ちないエラーの捕捉全般はRork で作ったアプリの原因不明クラッシュを撲滅する方法 にまとめています。
再生成に耐える置き場所を設計する
ここで一つ、AIビルダー特有の落とし穴があります。境界のコードを生成された fetch と同じファイルに書くと、その画面を作り直すよう指示したときに、せっかくの検証層ごと上書きされてしまうのです。
私は最初これで失敗しました。丁寧に parse を足した画面を「配色を変えて」と再生成したら、境界が消えて元の素朴な fetch に戻っていたのです。
対策は、検証境界を「AIが触る場所」から物理的に切り離すことです。具体的には次のように分けます。
置き場所 中身 再生成の対象
schemas/Zod スキーマ(契約) 触らせない
api/fetch + parse の境界関数 触らせない
app/ 画面UI と useProducts の呼び出し 再生成OK
画面側はフックを呼ぶだけにしておき、schemas/ と api/ はAIに再生成させないディレクトリとして扱います。こうすると、UIを何度作り直しても契約と境界は生き残ります。生成コードと手書きコードの共存境界という考え方は、無印 Rork と Rork Max を1つのバックエンドで支える API 契約設計 とも通じる発想です。
部分的に正しいデータをどう扱うか
実運用では「全部だめ」より「一部だけ壊れている」ほうがよく起きます。100件のうち3件だけ price が欠けている、というような状況です。ここで parse を使うと、3件のために100件全部が捨てられます。
これが望ましいかは、アプリの性質によります。金額を扱う画面なら、1件でも怪しければ全部止めることを私は推奨します。一覧を眺めるだけの画面なら、この場合は壊れた3件を除いて97件を見せたほうが親切です。
後者を選ぶなら、要素ごとに検査して壊れたものだけ落とす形にします。
// 壊れた要素だけ除外して、健全なものは見せる
function parseLenient ( json : unknown ) : Product [] {
const outer = z. object ({ items: z. array (z. unknown ()) }). safeParse (json);
if ( ! outer.success) return [];
const products : Product [] = [];
for ( const raw of outer.data.items) {
const one = ProductSchema. safeParse (raw);
if (one.success) {
products. push (one.data);
} else {
// 落とした件数を記録しておく — 静かに減らさない
logDroppedItem (one.error);
}
}
return products;
}
ここで大事なのは、落とした件数をログに残すことです。黙って減らすと、それはそれで「静かな壊れ方」になります。何件を、なぜ落としたかが分かれば、後から契約を直す判断ができます。
厳格に全部止めるのか、寛容に一部を見せるのか。この判断は境界の設計者が意識的に選ぶべきもので、生成コードに任せてはいけない部分です。
導入して実際に何が変わったか
私が運用している複数のアプリのうち、外部APIに依存する主要な一覧画面から順にこの境界を入れていきました。導入前後で、実感として変わったのは次の2点です。
一つは、原因調査の時間です。以前はレスポンス起因の不具合で、UIのどこで壊れたかを追うのに数十分かかっていました。境界を入れてからは、shape エラーのログにフィールド名が出るため、当たりをつけるのが数分で済むようになりました。
もう一つは、気づくタイミングです。境界を入れてから最初の3週間で、想定していなかったレスポンス形式の変化を2件、ユーザーからの報告ではなくログから先に検知できました。片方は前述の price の型揺れ、もう片方は任意フィールドがある日から必須のように振る舞い始めていたケースでした。どちらも画面が真っ白になる前に手を打てました。
分析イベント側で似たスキーマ崩れを自己監査した経験はAmplitude のファネルが一晩で改善したように見えたとき にも書きましたが、ネットワーク境界でも同じ「静かな崩れを早く炙り出す」発想が効きます。
境界は万能ではありません。スキーマの保守という手間は増えますし、契約を書く分だけ最初に時間がかかります。それでも、静かに間違うより、境界の1行で確実に気づけるほうを私は選びます。個人開発では、気づけない不具合が一番コストが高いからです。
最初の一歩は、大がかりに始めないことです。次の順で試してみてください。
外部APIに一番依存している画面を1つだけ選ぶ
その画面のレスポンス形を Zod スキーマとして schemas/ に書き出す
生成された fetch に parse を1行だけ足し、画面側はフックを呼ぶだけにする
境界が1枚あるだけで、次にAPIが変わったときの景色が変わります。