TypeScriptでAPIレスポンスを扱うとき、次のようなコードを書くことがあります。
type User = {
id: number;
name: string;
};
const response = await fetch("/api/user");
const user: User = await response.json();
userにはUser型が付いているので、一見すると型安全に見えます。
ここで、なんらかの手違いで、APIが実際には次のようなJSONを返してきたとします。
{
"id": "1",
"name": null
}
User型とは明らかに一致していません。
しかし、TypeScriptはこれを検出してくれません!
最初に示した実装は、実は型安全ではないのです。
なぜ型安全ではないのか
TypeScriptの型は実行時には存在しない
そもそもTypeScriptの型がどこまで面倒を見てくれるのかを確認します。
const name: string = 100;
上記のような例の場合、TypeScriptは型エラーを出します。
これはTypeScriptが、stringとして扱う値にnumberを代入しようとしている、と判断できるからです。
一方で、TypeScriptのコードはトランスパイルされ、最終的にはJavaScriptとして実行されます。
const name: string = "Taro";
これは、JavaScriptではおおよそ次のようになります。
const name = "Taro";
stringという型情報は実行時には残りません。
つまり、TypeScriptは「実際の値が本当にその型なのか」を実行時に確認しているわけではありません。
型注釈は値を検証しているわけではない
最初のコードをもう一度見てみます。
const user: User = await response.json();
このUserという型は、APIレスポンスそのものを検証しているわけではありません。
TypeScript上ではuserをUser型として扱えるようになりますが、APIから返ってきたJSONの中身が実際にUser型の構造になっていることまでは保証されません。
type User = {
id: number;
name: string;
};
const user: User = await response.json();
console.log(user.name.toUpperCase());
上記のコードについて、TypeScript上ではuser.nameはstringなので、型エラーは出ません。
しかしAPIが、
{
"id": 1,
"name": null
}
を返していた場合、実行時に「Cannot read properties of null」のようなエラーになります。
つまり、型を付けることと、値を検証することは別の話です。
as Userも検証はしていない
次のように型アサーションを書く場合も同様です。
const user = (await response.json()) as User;
as Userは「この値をUserとして扱う」とTypeScriptに伝えているだけです。
実際にUserかどうかを確認する処理ではありません。
極端な例ですが、以下のようなコードも書けます。
type User = {
id: number;
name: string;
};
const user = {
id: "abc",
name: null,
} as User;
つまりasは、値を安全にする仕組みではなく、型チェック上の扱いを変更する仕組みです。
どうすれば安全に扱えるのか
unknown型として受け入れる
外部から入ってくる値を信頼せず、一旦unknownとして扱います。
const data: unknown = await response.json();
unknownは、「何が入っているか分からない値」です。そのため、いきなりdata.name;のようには書けません。
これは不便に見えますが、実態に合った型ではあります。そのため、data.nameのように書いても、TypeScriptが「まだ中身を確認していないので、そのまま使ってはいけない」と止めてくれます。
型ガードによる検証
実際にUser型を満たしているかを検証する方法についてです。
type User = {
id: number;
name: string;
};
上記のようなUser型である場合、値がUser型を満たすかどうかを検証する関数を実装できます。
// : value is User は、この関数が true を返したとき、value は User 型として扱ってよいという意味
const isUser = (value: unknown): value is User => {
if (typeof value !== "object" || value === null) {
return false;
}
const user = value as Record<string, unknown>;
return (
typeof user.id === "number" &&
typeof user.name === "string"
);
};
これを使うと、
const response = await fetch("/api/user");
const data: unknown = await response.json();
if (!isUser(data)) {
throw new Error("Invalid API response");
}
console.log(data.name.toUpperCase());
と書けます。
isUser(data)を通過した後は、TypeScriptもdataをUserとして扱ってくれます。
整理すると、外部データは
- APIレスポンスを受け取る
- 一旦unknown型とする
- 受け取ったunknown型の値について、User型を満たしているか検証する
- User型の値とみなす
というように、APIから受け取った値をいきなりUserとして扱うのではなく、一度中身を確認してからUserとして扱うことで安全になります。
型ガード関数を書くのはダルい→Zodなどを使う
とはいえ、毎回こんな関数を書くのはかなり大変です。
Userくらいならまだいいのですが、実際のAPIレスポンスはもっと複雑になりがちです。
type User = {
id: number;
name: string;
profile: {
age: number;
address: {
prefecture: string;
city: string;
};
};
roles: string[];
};
こうした構造を毎回手書きで検証するのは大変です。
このような場合、Zodのようなスキーマバリデーションライブラリを使うと楽をする事ができます。
import { z } from "zod";
const UserSchema = z.object({
id: z.number(),
name: z.string(),
});
APIレスポンスを取得した後に、
const response = await fetch("/api/user");
const data: unknown = await response.json();
const user = UserSchema.parse(data);
とすれば、実際の値を実行時に検証できます。
{
"id": "1",
"name": null
}
上記のようなレスポンスであれば、検証に失敗します。
おまけ:型定義もスキーマから作れる
Zodでは、スキーマからTypeScriptの型を生成できます。
const UserSchema = z.object({
id: z.number(),
name: z.string(),
});
type User = z.infer<typeof UserSchema>;
これなら、
type User = ...
const UserSchema = ...
のように別々に定義する必要がなくなり、UserSchemaを変更したのにUser型を変更するのを忘れていた、ということがなくなります。
どこまで検証すべきか
では、APIレスポンスを全部Zodで検証すればいいのかというと、そこまではしなくていいかなと思っています。
例えば、外部APIのように、こちらではレスポンスをコントロールできないものや、フロントエンドとバックエンドを別チームで管理している場合は、実行時に検証する価値は高そうです。
一方で、フロントエンドとバックエンドを同じチーム・同じリポジトリで管理していて、OpenAPIから型を自動生成しているようなケースでは、全レスポンスに対してZodを書くのは少しやりすぎかもなとも思います。
このあたりは、
- 実行時検証を入れるコスト
- 間違った値が入ってきたときの影響
- APIをどこまで自分たちでコントロールできるか
あたりを見ながら決めるのが良さそうです。
ただし、型を共有できていることと、実際に届いた値を検証していることは別です。
例えばフロントエンドとバックエンドで同じUser型を共有していたとしても、ネットワーク越しに届いたJSONそのものが本当にその型になっているかをTypeScriptが実行時に確認しているわけではありません。
まとめ(感想)
これまで信頼できるバックエンドという状況が整っていることが多かったので、奇跡的にAPIレスポンスの検証を怠っていてもうまくいっていただけなのかもしれないと思いました。
バックエンド実装では、クライアントから送られて来たデータを無条件に信用せず、バリデーション等をかけてから受け入れるということは自然にできていましたが、この考えをフロント側に適用できていなかったことを反省しています。