JSON 轉 TypeScript:介面、型別守衛與生成邊界
結論:從範例 JSON 生成 interface 只完成了一半:可選欄位、null、聯合型別、日期與大整數都要人工確認,生成結果本身也不做執行時校驗。這篇給出生成、收斂與守衛的完整流程。

結論:先用範例或 schema 生成介面省掉打字,再逐項複核可選性與 null、日期與大整數保持 string,最後在外部資料邊界加執行時校驗;只把生成型別當契約,等於沒有校驗。
生成器只看得到你貼進去的那一條範例。欄位會不會缺、值會不會是 null、日期用什麼格式,它都只能猜——猜錯的部分要靠人補。
一、生成器到底做了什麼
把一段 JSON 貼進 JSON 轉 TypeScript,拿到的 interface 是一次形狀歸納:它統計出現過的鍵和值的型別,再選擇最窄的可用表示。
{
"id": 1,
"email": "ada@example.com",
"profile": { "nickname": "Ada", "age": 36 },
"tags": ["admin", "beta"],
"createdAt": "2026-10-31T08:00:00Z"
}
會得到大致這樣的結果:
interface User {
id: number;
email: string;
profile: { nickname: string; age: number };
tags: string[];
createdAt: string;
}
這段程式碼編譯通過,但它表達的是一個理想值,不是介面的真實約束。生成器不知道 email 會不會缺失、tags 會不會為空、createdAt 是不是永遠帶時區。它甚至不知道 id 在別的記錄裡可能是字串。
所以正確的預期是:生成器負責省掉打字,人負責補語義。
二、可選欄位與 null:? 和 | null 不是一回事
這是生成結果最常被直接沿用、也最容易出事的地方。
| TypeScript 寫法 | 含義 | 對應 JSON |
|---|---|---|
email: string |
一定存在,一定是字串 | {"email":"a@b.c"} |
email?: string |
鍵可以不存在;存在時不能是 null | {} 或 {"email":"a@b.c"} |
email: string | null |
鍵一定存在,值可以為 null | {"email":null} |
email?: string | null |
鍵可以不存在,值也可以為 null | 三種都可能 |
生成器只見過一條範例,通常會輸出第一種。如果 API 實際上會回傳 null,型別就撒了謊:編譯期一切正常,執行時 email.toLowerCase() 直接拋錯。
補法有兩條:
- 拿到多份真實範例,覆蓋「欄位缺失」和「值為 null」兩種情況,再讓生成器推斷;
- 生成後手動把欄位改成
?: T | null,並在程式碼裡真正處理這兩種分支。
不要為了省事全部寫成 ?: string。可選的意思是「鍵可能不存在」,不是「值可能是空」;undefined 和 null 在 JSON.stringify 裡的行為也不同(前者被省略,後者保留為 null)。
三、陣列元素不齊與聯合型別
陣列是最容易生成錯的結構。三條記錄裡 tags 分別出現 ["admin"]、[]、["beta", 3],生成器可能給出 string[]、(string | number)[] 或 unknown[],取決於範例。
需要人工判斷:
- 元素型別真的混合:寫聯合型別
(string | number)[],並在消費端收窄; - 只是某條資料髒:修資料,別用
any[]掩蓋; - 空陣列:生成器推不出元素型別,退化成
never[]或any[],必須手動補; - 固定長度元組:只有業務確實保證長度時才用
[string, string],否則後續加元素就編譯失敗。
物件陣列還要注意「鍵不齊」:
interface OrderItem {
sku: string;
quantity: number;
discount?: number; // 部分記錄沒有這個欄位
}
把可選性寫在元素型別裡,而不是給整個陣列加 ?。後者表示「陣列本身可能不存在」,完全是另一個意思。
四、日期、大整數、列舉:JSON 沒有的型別
JSON 只有字串和數字,業務語義全靠約定。生成器一律按字面量推斷,需要人工糾正:
| JSON 字面量 | 生成器常見輸出 | 應該改成 |
|---|---|---|
"2026-10-31T08:00:00Z" |
string |
string 保留在傳輸層,消費時轉 Date;不要直接標 Date |
1717075200000 |
number |
毫秒時間戳用 number,並註明單位 |
"1234567890123456789" |
string |
保持 string,避免 number 丟精度 |
1 / "1" 混用 |
number | string |
統一成一種,別把聯合型別帶進業務層 |
"admin" |
string |
字面量聯合 'admin' | 'editor',或列舉常量 |
true / "true" 混用 |
boolean | string |
修資料源,不要生成這種型別 |
最關鍵的一條:JSON 裡的日期永遠是字串。把 interface 寫成 createdAt: Date 是常見的自欺——JSON.parse 不會建立 Date 物件,執行時拿到的仍是字串,.getTime() 直接報錯。正確做法是傳輸層用 string,在邊界處顯式 new Date(value) 並校驗 Number.isNaN。
大整數同理。id 超過 2^53 時,JSON.parse 已經丟精度,生成 number 只是把這個錯誤固化進型別。讓後端回傳字串,前端保持字串,是唯一可靠的方案。
五、生成型別不做執行時校驗
TypeScript 的型別在編譯後全部抹除。interface 再精確,也攔不住一段 {"id": "x"} 的回應——它只約束你自己寫的程式碼,不約束網路傳來的位元組。
/**
* 判斷任意值是否為使用者物件。
* @param value 待校驗的執行時值
* @returns 是否滿足 User 的最小結構
*/
function isUser(value: unknown): value is User {
if (typeof value !== 'object' || value === null) {
return false;
}
const candidate = value as Record<string, unknown>;
return typeof candidate.id === 'number' && typeof candidate.email === 'string';
}
型別守衛只能覆蓋你寫進判斷的欄位。欄位一多,手寫守衛會變成第二份需要維護的定義,和 interface 一起漂移。
所以實際的取捨是:
| 方案 | 執行時校驗 | 型別來源 | 適合場景 |
|---|---|---|---|
| 只用 interface | 無 | 手寫或生成 | 內部可信資料、編譯期約束 |
| interface + 手寫守衛 | 有 | 兩份,易漂移 | 欄位少、邊界清晰 |
| zod / valibot | 有 | schema 推導型別 | 外部 API、表單、設定 |
| JSON Schema + 校驗器 | 有 | schema 生成型別 | 跨語言契約、文件化 |
如果資料來自外部,優先讓 schema 成為唯一來源,再用 z.infer 或 json-schema-to-ts 推導型別。這樣「校驗通過」和「型別正確」是同一件事,不會出現兩份定義。更完整的雙向同步做法見 Schema 與 TypeScript 型別雙向同步。
六、interface 還是 type:別在生成結果上糾結
生成器通常輸出 interface,但兩者在多數場景可以互換。真正需要區分的是三種能力:
- 宣告合併:
interface可以多次宣告並自動合併,type不行。給第三方型別打補丁時用得上,業務模型反而要避免——合併會讓「多出來的欄位」悄悄通過編譯。 - 聯合與對映:
type能直接寫A | B、Partial<T>、範本字面量;interface不能。 - 遞迴:兩者都支援,但複雜遞迴用
type更自然。
團隊裡更實用的規則是:物件形狀用 interface,聯合、工具型別、函式型別用 type。生成結果保持預設即可,不要在評審裡為這個爭論,把精力放在可選性和執行時校驗上。
七、工具鏈:三種生成方式的選擇
| 方式 | 輸入 | 優點 | 局限 |
|---|---|---|---|
| 站點 JSON 轉 TypeScript | 範例 JSON | 開啟即用,本地處理 | 只基於當前範例,需人工收斂 |
| quicktype | JSON / JSON Schema | 多語言輸出,CLI 可整合 | 推斷保守,複雜聯合型別偏寬 |
| json-schema-to-typescript | JSON Schema | 契約驅動,可重現 | 需要先有可靠的 schema |
quicktype 的典型用法:
npx quicktype -s json -o user.ts user.json --just-types
--just-types 只輸出型別、不生成執行時轉換程式碼,適合已有解析層的專案。若 schema 本身是手寫的或由後端產出,json-schema-to-typescript 更合適,因為它尊重 required、enum、additionalProperties 這些約束,而範例推斷看不到它們。
schema 生成環節本身也有坑,比如把所有欄位都標成必填、把 null 吞成可選,參見 JSON Schema 生成器的常見陷阱。
八、生成之後的落地清單
把生成結果提交前,至少過一遍這五項:
- 欄位命名統一:
userId與user_id不要同時出現,轉換層只做一次對映; - 可選性複核:對著真實回應確認哪些欄位會缺、哪些會是
null; - 日期與 id 保持字串:在型別上寫清楚單位與格式,消費處顯式解析;
- 收斂
any與unknown:生成器給any的地方,要麼補型別,要麼保留unknown並加守衛; - 加執行時校驗:外部資料至少校驗一次,別指望 interface 攔住壞資料。
如果專案同時使用多種 JSON 庫(原生 JSON.parse、superjson、lossless-json),大整數與日期的行為並不一致,選型時一併確認,可參考 JSON 解析庫選型。
常見問題
生成器為什麼把可選欄位都寫成了必填?
它只看到一條範例,所有出現過的鍵都被當成必有。解決辦法是提供多份覆蓋不同情況的範例,或者從 JSON Schema 生成——schema 裡的 required 才是權威來源。
interface 裡寫 Date 為什麼執行時報錯?
JSON.parse 只產生字串、數字、布林、null、物件和陣列,不會建構 Date。型別寫 Date 只是編譯期的承諾,執行時值仍是字串。傳輸層用 string,邊界處再轉 Date。
生成的型別能直接當校驗用嗎?
不能。TypeScript 型別在編譯後抹除,對執行時資料沒有任何約束。外部資料要用 zod、valibot、ajv 之類的校驗器,或手寫型別守衛。
大整數 id 應該生成 number 還是 string?
只要可能超過 2^53,就必須用 string。JSON.parse 在解析階段就會丟精度,生成 number 只是把錯誤寫進型別,後續無法補救。
如何選擇用範例 JSON 還是 JSON Schema 生成型別?
範例生成開啟即用,但只看到一條資料,欄位的可選性與列舉都靠猜。schema 驅動能看到 required、enum、additionalProperties 這些約束,結果可複現。已有可靠 schema 時用 json-schema-to-typescript,否則先用 quicktype 或站點工具,再人工收斂。