Tools mentioned in this article
Open the browser-based tool while you read and try the workflow immediately.
有些情況下「從範例推論」並不夠
本站已經有 JSON to Zod 轉換:貼上 API 回應,就能從值推論出 Zod 結構定義。
不過這個做法有原理上的極限。光看值的範例,無法判斷該欄位是否必填。 看到 {"nickname": "alice"},沒有辦法知道 nickname 是一定存在,還是這次剛好有值。同理,"[email protected]" 也無法決定該欄位就該是電子郵件格式。
而 JSON Schema 從一開始就把這些寫清楚了。
{
"type": "object",
"properties": {
"id": { "type": "integer" },
"email": { "type": "string", "format": "email" },
"nickname": { "type": "string" }
},
"required": ["id", "email"]
}

JSON Schema ⇄ Zod 轉換器 就是把這些資訊完整帶到 Zod,而不是丟掉;也能反向從您已寫好的 Zod 程式碼產生 JSON Schema。由於 OpenAPI 的 components.schemas 本身就是 JSON Schema,可以直接貼上使用。
最大的陷阱:required 與 .optional() 的預設值相反
兩份規格對於**「什麼都不寫代表什麼」**的看法正好相反。
| 如何表示必填 | 什麼都不寫時 | |
|---|---|---|
| JSON Schema | 把名稱列在 required 陣列中 | 選填(optional) |
| Zod | 不加上 .optional() | 必填 |
JSON Schema 是「沒有明說必填就是選填」,Zod 是「沒有明說選填就是必填」。搞反的話,就會產生一份所有該必填的欄位全都變成 optional 的型別。
轉換器會吸收這個差異。JSON Schema → Zod 方向只會替不在 required 中的屬性加上 .optional():
const required: string[] = Array.isArray(schema.required) ? schema.required : [];
const lines = Object.entries(properties).map(([key, value]) => {
let field = schemaToZod(value, depth + 1, warnings);
if (!required.includes(key)) field += '.optional()';
return `${indent}${formatKey(key)}: ${field},`;
});
反向轉換時,則把沒有 .optional() 的屬性收集成 required 陣列。上面的結構定義會轉成:
import { z } from 'zod';
export const schema = z.object({
id: z.number().int(),
email: z.string().email(),
nickname: z.string().optional(),
});
export type Schema = z.infer<typeof schema>;
z.infer 的型別別名也會一併輸出,讓驗證與型別定義出自同一處。
限制條件對照表
| JSON Schema | Zod |
|---|---|
"type": "integer" | z.number().int() |
"format": "email" | z.string().email() |
"format": "date-time" | z.string().datetime() |
"minLength": 3 | z.string().min(3) |
"minimum": 0 | z.number().min(0) |
"enum": ["a","b"](全為字串) | z.enum(["a", "b"]) |
"enum"(混雜其他型別) | z.union([z.literal(...), ...]) |
"type": ["string","null"] | z.string().nullable() |
"additionalProperties": false | .strict() |
oneOf / anyOf | z.union([...]) |
只有 enum 需要分支處理。Zod 的 z.enum() 只接受字串,因此含有數字或 null 的 enum 無法直接傳入,會退回成 literal 的 union。
Zod → JSON Schema 不會執行程式碼
反向轉換有一項實作上的限制。zod-to-json-schema 這類既有函式庫是接收執行期的 Zod 物件再加以檢視。但本站所有工具都堅持在瀏覽器內完成,因此絕不能把貼上的程式碼交給 eval。
所以工具是把 Zod 程式碼當成文字剖析。像 z.string().min(1).optional() 這樣的運算式,會被拆成呼叫名稱(string)、引數,以及鏈式方法(min(1)、optional())。
麻煩的是括號與字串字面值。單純尋找下一個 ) 會在巢狀時失效,而 z.literal('a,b') 這種含逗號的字串也會讓欄位切割出錯。因此尋找對應括號的處理會追蹤目前是否位於引號內:
const findClosing = (source: string, openIndex: number): number => {
let depth = 0;
let quote: string | null = null;
for (let i = openIndex; i < source.length; i += 1) {
const char = source[i];
if (quote) {
if (char === '\\') i += 1; // 跳過被跳脫的字元
else if (char === quote) quote = null;
continue;
}
if (char === '"' || char === "'" || char === '`') { quote = char; continue; }
if (char === '(' || char === '[' || char === '{') depth += 1;
else if (char === ')' || char === ']' || char === '}') {
depth -= 1;
if (depth === 0) return i;
}
}
return -1;
};
切割物件欄位時也採用同樣的想法:只有最上層的逗號才算分隔符,因此 z.object({ label: z.literal('a,b') }) 不會被破壞。
剖析會從第一個 z. 開始,所以就算連同 import { z } from 'zod'; 與 export const user = 一起複製也能運作。
無法轉換的部分不會被默默丟掉
$ref:不解析參照,該處會變成z.any()allOf:無法表達交集型別,只轉換第一個子結構if/then/not:不支援
若把這些默默變成 z.any(),會造成看起來轉換得很乾淨,實際上驗證全部放行的最糟結果。因此工具會在結果下方以警告顯示。
.refine() 這類任意函式也一樣,JSON Schema 沒有表達方式,反向轉換時會消失。請把產生的內容視為移轉的起點,務必自行確認。
什麼時候用
如果專案是從 OpenAPI 定義出發,只要貼上 components.schemas 的內容,就能同時取得前端的型別與驗證。反之若程式碼中先寫了 Zod,可以用反向轉換產生 API 文件用的 JSON Schema。
若手上只有 JSON 範例,仍然適合使用 JSON to Zod 轉換;想建立 OpenAPI 結構本身,則有 JSON to OpenAPI 轉換。所有工具都在瀏覽器內完成處理,貼上的結構定義不會傳送到外部。
常見問題
該用這個還是 JSON to Zod?
手上已有 JSON Schema(或 OpenAPI 定義)時請用這個,因為必填、enum、字串 format 等限制都會保留,產生的 Zod 結構定義更精確。若只有 API 回應之類的值範例,請用 JSON to Zod 轉換。
含有 $ref 的結構定義該怎麼辦?
參照不會被展開,該處會變成 z.any() 並顯示警告。若您的結構把共用定義抽到 $defs,請在貼上前先展開,或在產生後手動修改該部分。
產生的 Zod 結構定義可以直接用於正式環境嗎?
可以當作骨架,但原本不在 JSON Schema 中的業務規則不會被產生。另外 pattern 會被搬到 z.string().regex(),若原本的正規表示式不相容於 ECMAScript,行為可能會不同。請務必確認後再使用。
貼上的結構定義會被傳送嗎?
不會。JSON Schema ⇄ Zod 轉換器 在瀏覽器內完成處理,Zod 程式碼也是以文字剖析而非執行,因此就算貼上公司內部的 API 定義也不會外流。