JSONからTypeScriptへ:APIレスポンスから型を生成する
TypeScriptでREST APIを呼び出し、レスポンスを any と型付けしたことがあるなら、その後に続く苦痛を知っているでしょう。オートコンプリートが消えます。タイプミスがこっそり紛れ込みます。string だと思っていたフィールドが null だったことがわかり、アプリが本番環境でクラッシュします。
解決策は?JSONレスポンスを適切なTypeScript型に変換することです。このガイドでは、手動マッピング、自動化ツール、そしてAPI統合を堅牢に保つベストプラクティスを順を追って説明します。
JSONからのTypeScript型が重要な理由
TypeScriptの型システムは、エラーがユーザーに届く前にキャッチするために存在します。APIレスポンスを正確な型でモデリングすると、3つのことが起こります:
- オートコンプリートがどこでも機能します。 エディタがデータの形状を知っているため、フィールド名を推測する必要がなくなります。
- リファクタリングが安全になります。 プロパティ名を変更すると、コンパイラが壊れるすべての箇所を教えてくれます。
- ランタイムエラーが減少します。 アプリの奥深くではなく、境界で
null、undefined、予期しない形状を処理します。
型がなければ、本質的に余分なステップがあるJavaScriptを書いているだけです。型があれば、コードとそれが消費するデータの間の生きた契約を得ることができます。
手動JSON-to-TypeScriptマッピング
基本から始めましょう。JSONオブジェクトが与えられた場合、どのようにTypeScriptインターフェースに変換するのでしょうか?
基本オブジェクト
APIからの簡単なユーザーレスポンスです:
{
"id": 42,
"username": "jdoe",
"email": "jdoe@example.com",
"isActive": true
}
対応するTypeScriptインターフェース:
interface User {
id: number;
username: string;
email: string;
isActive: boolean;
}
各JSON値はTypeScriptのプリミティブにマッピングされます:number、string、または boolean。オブジェクトキーはプロパティ名になります。
ネストされたオブジェクト
実際のAPIがフラットな構造を返すことはほとんどありません。住所を持つユーザーを考えてみましょう:
{
"id": 42,
"username": "jdoe",
"address": {
"street": "123 Main St",
"city": "Springfield",
"zipCode": "62704",
"country": "US"
}
}
ネストされたオブジェクトを独立したインターフェースに分割します:
interface Address {
street: string;
city: string;
zipCode: string;
country: string;
}
interface User {
id: number;
username: string;
address: Address;
}
これにより各型が焦点を絞り、再利用可能になります。別のエンドポイントも Address を返す場合、同じインターフェースを使用できます。
配列
フィールドに配列が含まれている場合、要素の型を注釈します:
{
"id": 42,
"username": "jdoe",
"roles": ["admin", "editor"],
"posts": [
{ "id": 1, "title": "Hello World" },
{ "id": 2, "title": "TypeScript Tips" }
]
}
interface Post {
id: number;
title: string;
}
interface User {
id: number;
username: string;
roles: string[];
posts: Post[];
}
シンプルなケースでは Type[] 構文を使用します。複雑なジェネリクスの場合、Array<Type> の方が明確かもしれませんが、どちらを選ぶかよりも一貫性の方が重要です。
オプショナルフィールド
APIは存在するかもしれないし、しないかもしれないフィールドを返すことがよくあります。? でマークします:
// APIがbioを返す場合と返さない場合がある
{
"id": 42,
"username": "jdoe",
"bio": "Software developer"
}
interface User {
id: number;
username: string;
bio?: string; // レスポンスに存在しない場合がある
}
オプショナルフィールド(bio?: string)は、キー自体が完全に存在しない可能性があることを意味します。これは、キーは存在するが値が null である可能性があるnullableフィールドとは異なります。
エッジケースの処理
本番APIは乱雑です。一般的な問題の対処法は以下の通りです。
Nullableフィールド
フィールドが明示的に null になり得る場合:
{
"id": 42,
"username": "jdoe",
"avatarUrl": null,
"deletedAt": null
}
interface User {
id: number;
username: string;
avatarUrl: string | null;
deletedAt: string | null; // ISO日付文字列またはnull
}
フィールドが不在とnullの両方になり得る場合、2つを組み合わせます:
interface User {
id: number;
username: string;
middleName?: string | null; // 存在しないか、存在するがnullの可能性
}
ユニオン型
一部のフィールドは複数の型を受け入れます。検索APIが混合結果を返す場合があります:
{
"results": [
{ "type": "user", "id": 1, "username": "jdoe" },
{ "type": "post", "id": 2, "title": "Hello" }
]
}
判別共用体でモデリングします:
interface UserResult {
type: 'user';
id: number;
username: string;
}
interface PostResult {
type: 'post';
id: number;
title: string;
}
type SearchResult = UserResult | PostResult;
interface SearchResponse {
results: SearchResult[];
}
type フィールドが判別子として機能します。TypeScriptはチェック時に自動的に型を絞り込みます:
function renderResult(result: SearchResult) {
if (result.type === 'user') {
console.log(result.username); // TypeScriptはこれがUserResultであることを知っている
} else {
console.log(result.title); // TypeScriptはこれがPostResultであることを知っている
}
}
文字列値からのEnum
APIが固定の文字列値セットを返す場合、TypeScriptのenumの代わりに文字列リテラルユニオンを使用します:
{ "status": "active" }
// 可能な値:"active"、"inactive"、"suspended"
type UserStatus = 'active' | 'inactive' | 'suspended';
interface User {
id: number;
status: UserStatus;
}
文字列リテラルユニオンはよりシンプルで、ツリーシェイキングが可能で、enum のようにランタイムJavaScriptを生成しません。逆マッピング(数値から名前)が必要な場合にのみ enum を使用してください。
インターフェース vs 型エイリアス
interface と type の両方がオブジェクトの形状を記述できます。それぞれをいつ選ぶかは以下の通りです:
拡張する可能性のあるオブジェクト形状には interface を使用:
interface BaseEntity {
id: number;
createdAt: string;
updatedAt: string;
}
interface User extends BaseEntity {
username: string;
email: string;
}
ユニオン、インターセクション、計算された型には type を使用:
type ApiResponse<T> = {
data: T;
meta: { total: number; page: number };
};
type UserOrAdmin = User | Admin;
実際には、ほとんどの場合どちらでも機能します。プロジェクトで1つの規約を選び、それに従いましょう。多くのチームがデータモデルに interface、それ以外すべてに type を使用しています。
自動化ツールの使用
インターフェースを手動で型付けすることは小さなAPIには有効ですが、50以上のフィールドや深くネストされた構造を返すエンドポイントを扱う場合は退屈になります。
JSONを型に変換する前に、JSONが有効で適切にフォーマットされていることを確認してください。JSONフォーマッターは乱雑なAPIレスポンスをクリーンアップし、JSONバリデーターは時間を無駄にする前に構文エラーをキャッチします。
オンラインコンバーター
quicktype、json2tsなどのJSON-to-TypeScriptコンバーターツールを使えば、JSONペイロードを貼り付けて即座にインターフェースを取得できます。ネストされたオブジェクト、配列、オプショナルフィールドを自動的に処理します。
典型的なワークフロー:
- APIを呼び出してレスポンスをコピー
- JSONをコンバーターに貼り付け
- 生成された型を確認し、命名を調整
- TypeScriptをコードベースにコピー
OpenAPI仕様からのコード生成
APIにOpenAPI(Swagger)仕様がある場合、JSON変換を完全にスキップしてください。openapi-typescript などのツールが仕様から直接型を生成します:
npx openapi-typescript https://api.example.com/openapi.json -o ./src/types/api.ts
このアプローチが優れている理由は、仕様がオプショナルフィールド、nullable型、enumを明示的に定義しているからです。サンプルデータから推測することなく正確な型を取得できます。
Zodによるランタイムバリデーション
最大の安全性のために、Zodで形状を定義し、そこからTypeScript型を推論します:
import { z } from 'zod';
const UserSchema = z.object({
id: z.number(),
username: z.string(),
email: z.string().email(),
isActive: z.boolean(),
bio: z.string().nullable().optional(),
});
type User = z.infer<typeof UserSchema>;
// ランタイムでバリデーション
const response = await fetch('/api/users/42');
const data = await response.json();
const user = UserSchema.parse(data); // 形状が一致しない場合はエラーをスロー
Zodを使えば、型とバリデーションロジックが1か所に集まります。APIが形状を変更した場合、parse() は不正なデータがアプリ全体に伝播するのではなく、即座にエラーをスローします。
API呼び出しと型の統合
型はデータがアプリケーションに入る境界で適用してこそ有用です。
型付きfetchラッパー
型を強制する汎用fetch関数を作成します:
async function fetchJson<T>(url: string, schema?: z.ZodType<T>): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (schema) {
return schema.parse(data);
}
return data as T;
}
// 使用法
const user = await fetchJson<User>('/api/users/42');
const validatedUser = await fetchJson('/api/users/42', UserSchema);
Axiosと型
Axiosを使用する場合、レスポンスジェネリクスに型を適用します:
import axios from 'axios';
interface PaginatedResponse<T> {
data: T[];
meta: {
total: number;
page: number;
perPage: number;
lastPage: number;
};
}
async function getUsers(page: number = 1) {
const response = await axios.get<PaginatedResponse<User>>('/api/users', {
params: { page },
});
return response.data; // 完全に型付き
}
React Query / TanStack Query
Reactアプリでは、データフェッチライブラリと型を組み合わせます:
import { useQuery } from '@tanstack/react-query';
function useUser(id: number) {
return useQuery({
queryKey: ['user', id],
queryFn: () => fetchJson<User>(`/api/users/${id}`),
});
}
// コンポーネント内で
const { data: user, isLoading } = useUser(42);
// userはUser | undefinedとして型付けされる
ベストプラクティス
strictNullChecksを有効にする
tsconfig.json で strict モードを有効にします(少なくとも strictNullChecks):
{
"compilerOptions": {
"strict": true
}
}
strictNullChecksがなければ、TypeScriptは潜在的に null な値で .username にアクセスしても警告しません。これではAPIレスポンスを型付けする意味がなくなります。
不変データには readonly を使用
APIレスポンスは受け取ったデータであり、変更すべきデータではありません。プロパティを readonly でマークします:
interface User {
readonly id: number;
readonly username: string;
readonly email: string;
readonly createdAt: string;
}
またはインターフェース全体にユーティリティ型を使用します:
type ImmutableUser = Readonly<User>;
これにより、微妙なバグを引き起こす偶発的な変更を防ぎます。
IDのためのブランド型
ブランド型で異なるエンティティのID混同を防ぎます:
type UserId = number & { readonly __brand: 'UserId' };
type PostId = number & { readonly __brand: 'PostId' };
function getUser(id: UserId): Promise<User> { /* ... */ }
function getPost(id: PostId): Promise<Post> { /* ... */ }
const userId = 42 as UserId;
const postId = 7 as PostId;
getUser(userId); // OK
getUser(postId); // 型エラー!UserIdが必要な場所にPostIdを渡すことはできません
型の集中管理
すべてのAPI関連型を専用ディレクトリに保管します:
src/
types/
api/
user.ts
post.ts
common.ts // PaginatedResponseなどの共有型
index.ts // 再エクスポート
これにより型の検索、インポート、メンテナンスが容易になります。APIが変更された場合、1つのファイルを更新すれば、コンパイラが壊れるすべての箇所を表示します。
境界でバリデーション
外部データを決して信頼しないでください。完璧な型があっても、APIが予期しないものを返す可能性があります。レスポンスがアプリに入る場所でバリデーションを行います:
// こうしないでください
const user = (await response.json()) as User; // 盲目的に信頼
// こうしてください
const data = await response.json();
const user = UserSchema.parse(data); // 形状をバリデーション
型アサーション(as User)はコンパイラを黙らせますが、データをチェックしません。ランタイムバリデーション(Zod、io-ts、Valibot)は実際に形状が一致するかを検証します。
複雑なJSON構造の扱い
大規模または複雑なJSONペイロードを扱う場合、型を書く前に JSONエディター を使って構造を探索し理解してください。生テキストよりも適切なエディターでネストされた関係を見る方がはるかに簡単です。
JSON APIを扱うチームのための関連ガイド:
- JSONフォーマッティングのベストプラクティス — JSONをクリーンで読みやすく保つ
- JSON Schemaバリデーションガイド — JSON SchemaでAPIコントラクトをバリデーション
- JSON APIデザインパターン — 型付けしやすいAPIを設計
- JSONのバリデーション方法 — 不正なペイロードを早期にキャッチ
すべてを統合する
生のJSONからReactコンポーネントでのバリデーション済み型付きデータまで、すべてを結びつける完全な例です:
import { z } from 'zod';
// 1. スキーマを定義(唯一の信頼できる情報源)
const UserSchema = z.object({
id: z.number(),
username: z.string(),
email: z.string().email(),
isActive: z.boolean(),
role: z.enum(['admin', 'editor', 'viewer']),
profile: z.object({
bio: z.string().nullable(),
avatarUrl: z.string().url().nullable(),
socialLinks: z.array(z.object({
platform: z.string(),
url: z.string().url(),
})).optional(),
}),
});
// 2. スキーマから型を推論
type User = z.infer<typeof UserSchema>;
// 3. 型付きfetch関数を作成
async function getUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error(`ユーザー ${id} の取得に失敗`);
const data = await response.json();
return UserSchema.parse(data);
}
// 4. 完全な型安全性でアプリケーションで使用
const user = await getUser(42);
console.log(user.profile.bio); // string | null — コンパイラが知っている
console.log(user.profile.socialLinks); // 配列またはundefined — コンパイラが知っている
any なし。推測なし。ランタイムのサプライズなし。
まとめ
JSONをTypeScript型に変換することは、単にコンパイラを満足させることではありません。エラーを早期にキャッチし、エディターのオートコンプリートを強化し、コードベースのメンテナンスを容易にする安全ネットを構築することです。
シンプルに始めましょう:JSONレスポンスをコピーし、手動でインターフェースを書き、fetch呼び出しに型を適用してください。プロジェクトが成長するにつれ、ランタイムバリデーションにはZodを、大規模APIにはOpenAPIコード生成を採用してください。型エラーが本番環境に到達していたはずのバグを初めてキャッチした時、初期投資の価値が証明されます。