← 全部文章

如何從 JSON 產生 TypeScript 介面

學習如何把 JSON 轉成 TypeScript 介面 —— 手寫、線上工具或程式碼產生。涵蓋巢狀物件、選填欄位、陣列、可空型別,以及讓型別與 API 保持同步。

每個 TypeScript 開發者都遇過這種情況:你正在串接一個新的 API,手上有一份範例回應,在寫任何業務邏輯之前都需要先定義好型別化的 interface。手動撰寫這些 interface 既繁瑣又重複,還容易出錯 —— 尤其是回應巢狀很深或欄位數量眾多的時候。本文涵蓋將 JSON 轉換為 TypeScript interface 的各種實用方法:輸出應該長什麼樣、如何自動產生,以及如何處理棘手的情況。

為什麼 JSON 資料需要 TypeScript interface

在 TypeScript 中,不加型別註解就呼叫 API,解析得到的回應型別會是 any。這代表沒有自動完成、沒有型別檢查,也沒有編譯期的安全性。屬性名稱拼錯會變成執行時 bug,而不是編譯錯誤。

// 沒有型別 —— 一切都是 any
const response = await fetch('/api/users/42');
const user = await response.json(); // user: any
console.log(user.naem); // 拼字錯 —— 編譯時無錯誤

// 使用型別化的 interface
interface User {
  id: number;
  name: string;
  email: string;
}

const response = await fetch('/api/users/42');
const user: User = await response.json();
console.log(user.naem); // 錯誤: Property 'naem' does not exist on type 'User'

interface 在執行時沒有任何成本 —— 它只存在於編譯期。但它完全改變了開發體驗。

JSON 轉 TypeScript 後會產生什麼

每個 JSON 物件都會變成一個 TypeScript interface,每種 JSON 原生型別會對應到對應的 TypeScript 型別,陣列則會推斷其元素型別。

// 輸入的 JSON
{
  "id": 42,
  "name": "Alice Johnson",
  "email": "alice@example.com",
  "active": true,
  "score": 98.5,
  "address": {
    "street": "123 Main St",
    "city": "New York",
    "zip": "10001"
  },
  "tags": ["admin", "editor"],
  "orders": [
    { "id": 101, "total": 49.99, "status": "shipped" },
    { "id": 102, "total": 99.00, "status": "pending" }
  ]
}
// 輸出的 TypeScript
interface Root {
  id: number;
  name: string;
  email: string;
  active: boolean;
  score: number;
  address: Address;
  tags: string[];
  orders: Order[];
}

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

interface Order {
  id: number;
  total: number;
  status: string;
}

每個巢狀物件都會成為一個具名 interface。名稱來自父鍵 —— address 變成 Address,orders 中的元素變成 Order。這讓型別保持可讀且易組合。

型別對應規則

理解 JSON 型別如何對應到 TypeScript 型別,可讓輸出更可預測,也方便你放心地調整。

JSON 值TypeScript 型別
"hello"string
42, 3.14number
true, falseboolean
nullnull
{}具名 interface
['a', 'b']string[]
[1, 'two'](number | string)[]
[](空)unknown[]

一個小細節:TypeScript 沒有整數型別。423.14 都會變成 number。如果這個區別對你的用途很重要,可以使用品牌型別或加上註解。

方法一:使用線上 JSON 轉 TypeScript 工具

對於一次性任務 —— 新的 API 端點、快速原型 —— 線上工具是最快的方式。fixjson 的 JSON 轉 TypeScript 工具 完全在瀏覽器內完成轉換:

  1. 貼上你的 JSON 到輸入面板。工具接受合法 JSON,也接受帶有常見錯誤(如尾隨逗號、單引號)的損壞 JSON —— 它會在轉換前先修復。
  2. TypeScript interface 立即出現 在右邊 —— 無需按按鈕。
  3. 複製輸出,直接貼到你的 TypeScript 檔案中。

所有操作都在本地執行。任何資料都不會傳送到伺服器 —— 當你處理的 JSON 包含 API 金鑰、使用者資料或內部設定時,這點尤其重要。

方法二:用 TypeScript 自己寫轉換器

當你需要以程式方式產生型別 —— 作為建置腳本或程式碼產生器的一部分 —— 可以自己實作邏輯。以下是一個最小但完整的實作:

function jsonToTypeScript(value: unknown, rootName = 'Root'): string {
  const interfaces: string[] = [];
  const seen = new Set<string>();

  function cap(s: string) {
    return s ? s.charAt(0).toUpperCase() + s.slice(1) : 'Unknown';
  }

  function singular(name: string) {
    if (/ies$/i.test(name)) return name.slice(0, -3) + 'y';
    if (/[^s]s$/i.test(name)) return name.slice(0, -1);
    return name + 'Item';
  }

  function getType(val: unknown, name: string): string {
    if (val === null) return 'null';
    if (typeof val === 'boolean') return 'boolean';
    if (typeof val === 'number') return 'number';
    if (typeof val === 'string') return 'string';
    if (Array.isArray(val)) {
      if (val.length === 0) return 'unknown[]';
      const objs = val.filter(
        (v): v is Record<string, unknown> =>
          v !== null && typeof v === 'object' && !Array.isArray(v),
      );
      if (objs.length > 0) {
        const itemName = cap(singular(name));
        buildInterface(itemName, objs);
        return `${itemName}[]`;
      }
      const types = [...new Set(val.map((v) => getType(v, name)))];
      return types.length === 1 ? `${types[0]}[]` : `(${types.join(' | ')})[]`;
    }
    if (typeof val === 'object') {
      const iName = cap(name);
      buildInterface(iName, [val as Record<string, unknown>]);
      return iName;
    }
    return 'unknown';
  }

  function buildInterface(name: string, objs: Record<string, unknown>[]): void {
    if (seen.has(name)) return;
    seen.add(name);
    const allKeys = [...new Set(objs.flatMap((o) => Object.keys(o)))];
    const props: string[] = [];
    for (const key of allKeys) {
      const present = objs.filter((o) => key in o);
      const optional = present.length < objs.length ? '?' : '';
      const propType = getType(present[0][key], cap(key));
      const safeKey = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : `"${key}"`;
      props.push(`  ${safeKey}${optional}: ${propType};`);
    }
    interfaces.push(`interface ${name} {\n${props.join('\n')}\n}`);
  }

  const rootType = getType(value, rootName);
  const defs = interfaces.reverse().join('\n\n');
  if (rootType !== cap(rootName)) {
    return `type ${cap(rootName)} = ${rootType};\n\n${defs}`.trim();
  }
  return defs;
}

在 Node.js 建置腳本中使用:

import * as fs from 'fs';

const json = JSON.parse(fs.readFileSync('api-response.json', 'utf8'));
const typescript = jsonToTypeScript(json, 'UserResponse');
fs.writeFileSync('types/user.ts', typescript);

方法三:quicktype(CLI 與函式庫)

quicktype 是功能最完整的開源工具,可從 JSON 產生 TypeScript 型別。它能處理簡單產生器會漏掉的邊界情況,並支援多種輸出格式。

npm install -g quicktype

# 從本地檔案
quicktype api-response.json -o types/user.ts --lang typescript

# 從 URL
quicktype https://api.example.com/users/42 -o types/user.ts --lang typescript

# 從 stdin
cat api-response.json | quicktype --lang typescript

quicktype 也能用 io-ts 或 zod 等函式庫產生執行時驗證函式 —— 當你需要驗證傳入資料而不僅是靜態型別時很有用。

處理棘手的情況

可為 null 的欄位

JSON API 常常用 null 表示缺失的值。產生器看到 null 時會把型別產生為 null —— 但你通常想要的是 string | null:

// 自動產生(第一版 —— 太窄)
interface Root {
  name: string;
  middleName: null;
}

// 你應該改成
interface Root {
  name: string;
  middleName: string | null;
}

產生後務必手動檢查可為 null 的欄位。如果你有多份範例回應,某個欄位有時是字串、有時是 null,好的產生器會自動推斷出 string | null

選擇性 vs 可為 null

TypeScript 區分 field?: string(鍵可能不存在)和 field: string | null(鍵一定存在,值可能為 null):

interface Order {
  id: number;
  total: number;
  discount?: number;   // 部分回應中沒有這個鍵
  note: string | null; // 鍵一定存在,值可能為 null
}

能分析多份 JSON 範例的產生器可以精準推斷出這個差別。單樣本產生器會把缺少的鍵標為 optional,但無法區分鍵不存在和值為 null。

不是合法識別字的鍵

有些 JSON 鍵包含 TypeScript 屬性名稱不合法的字元:

// JSON
{ "content-type": "application/json", "x-request-id": "abc123" }

// 產生的 TypeScript —— 合法,但需要用中括號取值
interface Root {
  "content-type": string;
  "x-request-id": string;
}

// 用中括號取值
obj["content-type"]

如果你能掌控 API,優先使用駝峰式命名以徹底避免此問題。如果不行,加引號的屬性寫法在 TypeScript 中完全合法且可運作。

混合型別的陣列

// JSON
{ "values": [1, "two", true, null] }

// 產生結果
interface Root {
  values: (number | string | boolean | null)[];
}

這種聯合型別技術上正確,但通常代表 API 設計有問題。看到這種狀況時,先確認該欄位是真的混合型別,還是樣本資料有誤導性。

安全使用產生的型別

產生 interface 只是第一步。interface 是編譯期合約 —— 在執行時不會驗證任何東西。response.json() 回傳 any,TypeScript 會讓你把它斷言成 interface,並不會真的檢查。

對於雙方都由你掌控的內部 API,通常沒問題。對外部 API 或使用者提供的資料,要在執行時驗證。建議使用 Zod:

import { z } from 'zod';

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
  active: z.boolean(),
  address: z.object({
    street: z.string(),
    city: z.string(),
    zip: z.string(),
  }),
  tags: z.array(z.string()),
  orders: z.array(z.object({
    id: z.number(),
    total: z.number(),
    status: z.string(),
  })),
});

type User = z.infer<typeof UserSchema>; // 從 schema 推導的 TypeScript 型別

const raw = await response.json();
const user = UserSchema.parse(raw); // 形狀不符就丟錯

Zod 的做法兼顧兩面:schema 是 TypeScript 型別和執行時驗證的唯一真相來源。如果 API 有破壞性變更,你會在資料進入系統的當下立刻知道。

超越 interface:驗證器與型別化用戶端

產生的 interface 是編譯期合約。再進一步是使用一些函式庫,從單一來源同時取得型別和執行時檢查:

  • Zod io-ts —— 定義 schema,從中推導 TypeScript 型別,並在邊界驗證傳入資料。如果 API 改變,建置(以及錯誤紀錄)會立即告訴你。
  • tRPC 型別化的 fetch 包裝(例如 openapi-fetch)—— 伺服器端的程序或 OpenAPI 定義是真相來源,用戶端自動取得對應的型別,中間沒有 JSON 到 TS 的步驟。
  • ts-pattern 與可辨識聯合 —— 一旦你的資料帶有標籤欄位(例如 { kind: "circle" | "square", ... }),窮盡式模式比對能在編譯時抓出遺漏的情況。

讓型別與 API 保持同步

產生的型別有保存期限。API 會變 —— 欄位被改名、新增必填欄位、或字串欄位變成可為 null。你的 interface 會悄悄地過時。

從 OpenAPI 規格產生,而不是從範例回應

如果 API 有 OpenAPI (Swagger) 定義,openapi-typescript 可以直接從規格產生型別:

npm install -g openapi-typescript
openapi-typescript https://api.example.com/openapi.json -o types/api.ts

這比從範例回應產生可靠得多,因為規格會記錄每個欄位,包括 optional、可為 null 變體和列舉值。

在 CI 中重新產生型別

在 CI 流水線中加入一步:從規格或已知正確的樣本重新產生型別,然後執行 tsc --noEmit 檢查是否有壞掉的地方。如果 API 改變,建置會在破壞性變更進入正式環境前就失敗。

常見問題

如何將 JSON 轉換為 TypeScript interface?

把 JSON 貼到像 fixjson 的 JSON 轉 TypeScript 工具 這樣的線上工具立即取得結果;在建置步驟中執行 quicktype;或對長期使用的程式碼,用 openapi-typescript 從 OpenAPI 規格產生。

JSON 型別如何對應到 TypeScript 型別?

字串 → string,所有數字 → number(TypeScript 沒有整數型別),布林 → boolean,物件 → 具名 interface,陣列推斷其元素型別。請見上方的對應表與 JSON 的六種資料型別

如何處理可為 null 或 optional 的欄位?

鍵可能不存在時用 field?: T;鍵一定存在但值可能為 null 時用 field: T | null。能分析多個樣本的產生器會推斷出這點;單樣本工具則需要手動檢查。

TypeScript interface 會在執行時驗證 JSON 嗎?

不會 —— interface 在編譯時就被擦除。對外部資料若需要執行時安全,請用 Zod 進行驗證,或用 JSON Schema 描述形狀。

結語

將 JSON 轉成 TypeScript interface 是每個 TypeScript 開發者經常要做的事。最適合的方法取決於情境:對於一次性的快速串接,線上轉換器 可讓你不到一分鐘就動工。對於建置流水線,quicktype 或自訂腳本能自動化此流程。對於長期生產程式碼,從 OpenAPI 規格產生型別,並用 Zod 在執行時驗證,提供最強的保證。

無論用哪種方法,產生的 interface 都只是起點 —— 檢查可為 null 的欄位、為非顯而易見的屬性加註解、在業務規則比 JSON 樣本嚴格的地方收緊約束。能準確反映資料的型別,讓整個程式碼庫更易維護。

如果你手上的 JSON 在嘗試轉換之前就有語法錯誤,請先修復它。TypeScript 產生器 —— 跟任何 JSON 解析器一樣 —— 都需要語法上合法的輸入。