跳至內容

JSON 轉 TypeScript 實務

由 JSON 範本產生 TypeScript 型別,處理選擇性、可空值、聯合型別、巢狀型別,並結合執行期驗證策略。

當你呼叫一個回傳 JSON 的 API,TypeScript 需要對應的型別。一個有 40 欄、5 層巢狀的回應若靠手寫 interface,既痛苦又容易出錯。從範本生成 只要幾秒。本文涵蓋實務流程:從範本到型別、生成器常犯的錯,以及如何 加上執行期驗證,避免你的型別變成謊言。

為什麼要從 JSON 生型別

放棄手寫的兩個理由:

  • 完整性——生成器看得到範本中的每個欄位,不會漏掉你掃讀時跳過的欄位。
  • 同步性——API 加欄位時,重新生成只要 5 秒。手寫型別會腐爛。

代價是——生成器只根據單一範本推論型別。若範本沒包含某個 nullable 欄位 為 null 的情況,生成器會標成非 nullable。輸出永遠需要審視。

從範本到 interface

給定這份 JSON:

{
  "id": "user_123",
  "email": "ada@example.com",
  "age": 36,
  "active": true,
  "tags": ["admin", "founder"],
  "address": {
    "street": "10 Downing",
    "city": "London"
  }
}

合理的輸出:

interface User {
  id: string;
  email: string;
  age: number;
  active: boolean;
  tags: string[];
  address: Address;
}

interface Address {
  street: string;
  city: string;
}

工具:

處理選擇性與可空欄位

生成器看的是單一範本。兩件事幾乎都要人工修:

選擇性欄位。若某欄有時出現、有時不在,你要的是 field?: T 而非 field: T。提供多份範本,多數工具會合併並從「沒每份都出現」推論為 optional。

可空欄位。可能是 null 的欄位應標成 T | null

interface User {
  // 有時出現、有時不在
  middleName?: string;
  // 永遠出現,有時為 null
  deletedAt: string | null;
  // 可能不存在,也可能為 null
  avatarUrl?: string | null;
}

三者意義不同,TypeScript 對差異很嚴格。不確定時請看 API 文件,而非 範本。

由異質陣列產出聯合型別

當陣列包含不同形狀的物件,生成器會發出聯合:

{
  "events": [
    { "kind": "click", "target": "button-1" },
    { "kind": "page-view", "path": "/home" }
  ]
}
type Event =
  | { kind: "click"; target: string }
  | { kind: "page-view"; path: string };

字面型別 "click"(而非 string)讓它變成 判別聯合,TypeScript 能依 kind 收斂:

function handle(event: Event) {
  if (event.kind === "click") {
    event.target; // 推論為 string
  } else {
    event.path; // 推論為 string
  }
}

若生成器吐出 kind: string 而非 kind: "click",請手動緊縮。判別子 幾乎都值得收斂。

命名與巢狀型別

生成器以父欄位命名巢狀型別——User.addressAddressOrder.shippingAddressShippingAddress。兩個問題:

  • 衝突——文件不同地方各有一個 Address。請改名(BillingAddressShippingAddress)。
  • 內聯 vs 命名——一次性的小型別內聯即可。跨多個 endpoint 共用的,給名字並 export。

/zh-Hant/json/types/typescript 可指定 根名稱並依字母排序輸出所有巢狀型別。

as const 鎖死字面型別

若 JSON 是 fixture(測試資料、設定),希望 TypeScript 知道 確切的 值 而不只是型別,用 as const

const config = {
  env: "production",
  retries: 3,
  features: ["billing", "auth"],
} as const;
// 型別: { readonly env: "production"; readonly retries: 3; readonly features: readonly ["billing", "auth"] }

對 switch 與 exhaustiveness check 很有用。

執行期驗證 vs 編譯期型別

生成的型別是 編譯期合約。執行期 API 可能回傳任何東西。直接 cast 就是說謊:

const user = JSON.parse(text) as User; // ← 謊言

…日後 user.emailundefined 時會出現詭異錯誤。

穩健的模式是用執行期驗證器一次定義形狀,再由它推出 TypeScript 型別。以 Zod:

import { z } from "zod";

const User = z.object({
  id: z.string(),
  email: z.string().email(),
  age: z.number().int(),
  active: z.boolean(),
  tags: z.array(z.string()),
  address: z.object({
    street: z.string(),
    city: z.string(),
  }),
});

type User = z.infer<typeof User>;

function parseUser(text: string): User {
  return User.parse(JSON.parse(text)); // 形狀不符即拋出
}

類似函式庫:

  • Yup——較老,類 Joi API。
  • io-ts——fp-ts 生態,較冗長、較強。
  • Valibot——比 Zod 小 bundle,API 相似。
  • @sinclair/typebox——以 JSON Schema 為核心,能輸出合法 JSON Schema。

解析安全性深入內容請看 在 JavaScript 中安全解析 JSON

與 API 保持同步

由便宜到可靠:

  • 手動——貼範本、重新生成、commit。小型或變動少的 API 可。
  • schema 驅動——若 API 已發佈 JSON Schema 或 OpenAPI,以 openapi-typescriptjson-schema-to-typescript 直接生型別。schema 是合約,型別由它衍生。
  • 端到端——tRPC、GraphQL Code Generator,或前後端共享型別套件。編譯器替你把兩端鎖在一起。

策略應視「漂移成本」而定。對你不擁有的公開 REST API,手動再生就夠了; 團隊自有 API 建議走 schema 驅動或型別共享。

動手生

把範本貼進 JSON 轉 TypeScript 工具, 即可取得 TypeScript、Go、Python、Rust、Java、Swift、Kotlin、C# 的型別。 搭配執行期驗證器即為生產安全的模式。

延伸閱讀