JSON 轉 TypeScript:從 API 回應產生型別
如果你曾經在 TypeScript 中呼叫 REST API 並將回應型別設為 any,你就知道隨之而來的痛苦。自動補全消失了。打字錯誤悄悄溜進來。一個你以為是 string 的欄位結果是 null,你的應用程式在正式環境中崩潰了。
解決方案?將你的 JSON 回應轉換為適當的 TypeScript 型別。在本指南中,我們將逐步介紹手動對應、自動化工具以及讓你的 API 整合穩如磐石的最佳實務。
為什麼 JSON 的 TypeScript 型別很重要
TypeScript 的型別系統存在的意義是在錯誤到達使用者之前捕獲它們。當你用精確的型別建模 API 回應時,會發生三件事:
- 自動補全無處不在。 你的編輯器知道資料的形狀,所以你不再需要猜測欄位名稱。
- 重構變得安全。 重新命名一個屬性,編譯器會告訴你每個出問題的地方。
- 執行時期錯誤減少。 你在邊界處理
null、undefined和意外形狀,而不是在應用程式的深處。
沒有型別,你本質上是在寫帶有額外步驟的 JavaScript。有了型別,你就得到了程式碼和它消費的資料之間的活的契約。
手動 JSON 到 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。
處理邊緣情況
正式環境的 API 是混亂的。以下是如何處理常見問題。
可空欄位
當欄位可以明確為 null 時:
{
"id": 42,
"username": "jdoe",
"avatarUrl": null,
"deletedAt": null
}
interface User {
id: number;
username: string;
avatarUrl: string | null;
deletedAt: string | null; // ISO 日期字串或 null
}
如果欄位既可以不存在又可以為 null,則將兩者結合:
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
}
}
從字串值建立列舉
當 API 回傳固定的字串值集合時,使用字串字面量聯合而不是 TypeScript 列舉:
{ "status": "active" }
// 可能的值:"active"、"inactive"、"suspended"
type UserStatus = 'active' | 'inactive' | 'suspended';
interface User {
id: number;
status: UserStatus;
}
字串字面量聯合更簡單、可被 tree-shaking、不像 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;
實際上,大多數情況下兩者都可以。為你的專案選擇一個慣例並堅持下去。許多團隊對資料模型使用 interface,對其他一切使用 type。
使用自動化工具
手動輸入介面對小型 API 有效,但當你處理回傳 50 多個欄位或深度巢狀結構的端點時就變得繁瑣了。
在將 JSON 轉換為型別之前,確保你的 JSON 是有效且格式良好的。我們的 JSON 格式化工具 可以清理混亂的 API 回應,JSON 驗證器 可以在浪費你時間之前捕獲語法錯誤。
線上轉換器
quicktype、json2ts 等 JSON 轉 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
這種方法更優越,因為規範明確定義了可選欄位、可空型別和列舉。你無需從範例資料猜測就能取得準確的型別。
使用 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,你的型別和驗證邏輯在同一個地方。如果 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
最佳實務
啟用嚴格 null 檢查
在你的 tsconfig.json 中,啟用 strict 模式(至少啟用 strictNullChecks):
{
"compilerOptions": {
"strict": true
}
}
沒有嚴格 null 檢查,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>;
這可以防止導致細微 bug 的意外變更。
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); // 型別錯誤!不能將 PostId 傳遞到期望 UserId 的地方
集中管理型別
將所有 API 相關型別保存在專用目錄中:
src/
types/
api/
user.ts
post.ts
common.ts // 共享型別如 PaginatedResponse
index.ts // 重新匯出
這使型別易於尋找、匯入和維護。當 API 更改時,你更新一個檔案,編譯器會顯示所有中斷的地方。
在邊界處驗證
永遠不要信任外部資料。即使有完美的型別,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 進行執行時期驗證,使用 OpenAPI 程式碼產生處理大型 API。當型別錯誤第一次捕獲到一個本會到達正式環境的 bug 時,前期投資就會得到回報。