前幾天我們已經完成 Notes API 的 CRUD,現在可以查詢、新增、修改與刪除 Note。不過目前的 API 有一個問題,Request 收到資料之後,後端基本上會直接使用,並沒有先確認這些資料是否符合預期。
例如新增 Note 時,可能會直接這樣寫:
router.post("/notes", (req, res) => {
const note = {
id: nextId++,
title: req.body.title,
content: req.body.content,
};
notes.push(note);
res.status(201).json(note);
});
如果送來的資料缺少 title,title 就會是 undefined;如果 title 傳進來的是數字,程式也可能照樣執行。對 JavaScript 來說這些都是合法的值,但對 Notes API 而言卻不一定是我們想接受的資料。
因此,在 API 真正處理 Request 之前,還需要先確認資料是否符合預期,這個過程就是 Validation。
前端通常也會先檢查使用者輸入,例如確認 title 不可以空白,讓使用者在送出表單之前就能發現問題。不過,前端的驗證不能取代後端的 Validation,因為最後真正接收並處理 Request 的還是後端,所以後端仍然需要自己確認收到的資料是否符合 API 定義的規則。
可以把兩者的關係想成:
使用者輸入資料
↓
前端先檢查
↓
送出 Request
↓
後端再次驗證
↓
符合規則 → 繼續處理
不符合規則 → 回傳錯誤
前端驗證主要是讓使用者更早發現輸入問題,後端驗證則是確保真正進入 API 的資料符合預期,因此通常兩邊都需要做。
Validation 不一定需要任何套件,最基本的方法就是直接使用 if 判斷。假設我們規定 Note 的 title 與 content 都必須存在、必須是字串,而且不能是空白,就可以先在 Route 裡面這樣處理:
router.post("/notes", (req, res) => {
const { title, content } = req.body;
if (typeof title !== "string") {
return res.status(400).json({
message: "title 必須是字串",
});
}
if (title.trim() === "") {
return res.status(400).json({
message: "title 不可以是空白",
});
}
if (typeof content !== "string") {
return res.status(400).json({
message: "content 必須是字串",
});
}
if (content.trim() === "") {
return res.status(400).json({
message: "content 不可以是空白",
});
}
const note = {
id: nextId++,
title: title.trim(),
content: content.trim(),
};
notes.push(note);
res.status(201).json(note);
});
從這段程式可以整理出一份 Note 的資料規則,title 必須存在、必須是 string,而且不能是空白,content 也是同樣的規則。
這種寫法本身沒有問題,尤其在 API 很少、驗證規則也很簡單的情況下,直接自己寫反而容易理解。不過,當 API 慢慢增加,驗證條件也會變得更多,例如字串長度、數字範圍、陣列格式,甚至不同欄位之間的關係。如果每一個 Route 都自己寫一套驗證程式,規則就會開始重複出現在不同地方,也不容易統一修改。
因此,接下來可以把這些資料規則獨立整理出來。
Schema 可以理解成「描述一份資料應該符合哪些規則」。
其實剛才的 if 已經包含了一份 Schema,只是規則散落在程式流程中。現在把它整理出來,就會變成:
Note
├── title
│ ├── 必填
│ ├── 必須是字串
│ └── 不可以是空白
│
└── content
├── 必填
├── 必須是字串
└── 不可以是空白
Schema 並不是某個特定套件,而是一個概念。它描述的是資料應該符合什麼規則,至於這些規則要怎麼執行,可以自己寫程式,也可以交給專門的工具。
這次使用 Zod,讓我們把剛才自己寫的 Validation 規則整理成 Schema。
前面的程式主要把 API 邏輯和 Validation 都放在 Route 裡。今天加入 Schema 和 Middleware 之後,會開始把不同責任拆開。
完成後,專案會變成:
project/
├── app.js
├── routes/
│ └── notes.js
├── schemas/
│ └── note.js
└── middlewares/
├── validate.js
└── errorHandler.js
這幾個部分各自負責不同事情。app.js 負責組合 Express Application,routes/ 負責 API Endpoint 與 API 邏輯,schemas/ 負責定義 API 的資料規則,validate.js 負責執行 Validation,而 errorHandler.js 則負責統一處理錯誤。
因此,一筆 Request 進入系統之後,大致會經過:
Request
↓
express.json()
↓
Validation Middleware
↓
Schema
↓
Route
↓
Business Logic
↓
Response
這樣安排之後,Route 就不需要同時負責資料驗證,資料規則也不需要散落在不同 API 中。
先安裝 Zod:
npm install zod
因為目前專案使用 CommonJS,所以可以這樣引入:
const { z } = require("zod");
接著建立 schemas/note.js,把前面整理好的規則寫成 Zod Schema:
const { z } = require("zod");
const noteSchema = z.object({
title: z.string({
error: "title 必須是字串",
}).trim().min(1, {
error: "title 不可以是空白",
}),
content: z.string({
error: "content 必須是字串",
}).trim().min(1, {
error: "content 不可以是空白",
}),
});
module.exports = {
noteSchema,
};
如果和前面的手寫版本對照,就會比較容易理解 Zod 做了什麼。原本需要自己判斷 title 的型別,以及判斷它是不是空白,現在可以把這些條件集中寫進 Schema。
if (typeof title !== "string") {
...
}
if (title.trim() === "") {
...
}
對應到 Zod:
title: z.string({
error: "title 必須是字串",
}).trim().min(1, {
error: "title 不可以是空白",
}),
兩種方式其實是在處理同樣的問題,差別在於原本由我們自己撰寫每一個判斷,現在則把資料規則集中描述在 Schema 裡,再交給 Zod 執行。
safeParse() 驗證 Request建立 Schema 之後,就可以把 req.body 交給 Zod:
const result = noteSchema.safeParse(req.body);
safeParse() 會回傳驗證結果。成功時可以從 result.data 取得解析後的資料,失敗時則可以從 result.error 取得 Validation Error。Zod 的錯誤資訊會放在 issues 陣列中,因此一筆 Request 可能同時產生多個錯誤。
例如:
{
"title": 123,
"content": true
}
可能同時產生:
title
→ 必須是字串
content
→ 必須是字串
如果直接在 Route 裡處理,可以先寫成:
const result = noteSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
message: "Request 資料格式錯誤",
errors: result.error.issues,
});
}
const note = {
id: nextId++,
title: result.data.title,
content: result.data.content,
};
notes.push(note);
這時候可以看出,Zod 並不是讓原本做不到的 Validation 變得做得到,而是把原本分散的資料規則整理成 Schema,再交給工具執行。
如果只有一支 API,直接在 Route 裡使用 safeParse() 沒有太大問題。不過當 POST /notes、PUT /notes/:id、POST /users 等 API 都需要 Validation 時,每個 Route 都會出現一段相似的驗證程式。
這時候可以利用 Express Middleware,把 Validation 從 Route 中抽離,讓每支 API 只需要提供自己的 Schema。
建立 middlewares/validate.js:
const validate = (schema) => {
return (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
const issue = result.error.issues[0];
const error = new Error(issue.message);
error.statusCode = 400;
return next(error);
}
req.body = result.data;
next();
};
};
module.exports = validate;
這個 Middleware 會接收 Schema,先使用 Schema 驗證 req.body。如果驗證失敗,就把錯誤交給 Error Handler;如果驗證成功,就把 result.data 放回 req.body,讓後面的 Route 使用已經驗證過的資料。
Route 就可以變得簡單很多:
const validate = require("../middlewares/validate");
const { noteSchema } = require("../schemas/note");
router.post(
"/notes",
validate(noteSchema),
(req, res) => {
const note = {
id: nextId++,
title: req.body.title,
content: req.body.content,
};
notes.push(note);
res.status(201).json(note);
}
);
Route 現在只需要處理「資料驗證成功之後要做什麼」,而不需要知道每個欄位的驗證規則。
這裡還有一個實務上很重要的問題。Zod 會產生自己的 Validation Error 結果,但 API 文件可能會規定另外一套錯誤格式,因此不能直接把 Zod 的 issues 視為 API 最終的 Response。
例如 API 文件可能規定 400 Bad Request 一次只回傳一個錯誤訊息,但 Zod 在一筆 Request 中可能同時找到多個問題。
假設收到:
{
"title": 123,
"content": true
}
Zod 可能會得到:
title
→ 必須是字串
content
→ 必須是字串
如果直接把 result.error.issues 整包回傳,就會和「一次只回傳一個錯誤」的 API 規格不同。
這時候不需要修改 Zod,而是由 Validation Middleware 決定如何處理 Zod 的結果。前面的:
const issue = result.error.issues[0];
就是最簡單的做法,代表這支 API 一次只取一個錯誤。
不過,如果 API 文件有明確規定錯誤的優先順序,就不要單純依賴第一個 issue。例如 API 規定 title 的錯誤一定優先於 content,就應該在 Middleware 裡明確寫出這個規則:
const issue =
result.error.issues.find(
(issue) => issue.path[0] === "title"
) || result.error.issues[0];
const error = new Error(issue.message);
error.statusCode = 400;
return next(error);
這樣 Zod 可以負責找出所有問題,而 Middleware 再依照 API Contract 決定最後要回傳哪一個。
接著由 Error Handler 統一輸出 Response:
const errorHandler = (err, req, res, next) => {
const statusCode = err.statusCode || 500;
res.status(statusCode).json({
message: err.message || "Internal Server Error",
});
};
module.exports = errorHandler;
在 app.js 中:
app.use("/notes", notesRouter);
app.use(errorHandler);
這樣就形成完整的責任分工:
Zod
→ 找出資料有哪些問題
Validation Middleware
→ 按照 API Contract 整理 Validation 結果
Error Handler
→ 統一輸出 API Response
如果未來有很多支 API 都使用相同的錯誤格式,當 API 文件需要調整時,就可以集中修改這些共用邏輯,而不是每支 API 都重新處理一次。
另外,Schema 不只存在於 API 層。今天建立的 noteSchema 是 API Schema,主要描述 API 接受什麼樣的資料,例如 POST /notes 要求 title 和 content 都必須是字串。
之後真正接上資料庫時,資料庫也會有自己的 Database Schema,描述資料表有哪些欄位,以及資料庫層面的限制。
例如:
CREATE TABLE notes (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title VARCHAR(100) NOT NULL,
content TEXT NOT NULL
);
API 收到的資料可能只有:
{
"title": "學習 Zod",
"content": "今天學 API"
}
但資料庫裡除了這兩個欄位,還可能有 id、created_at、updated_at。這些欄位可能由後端或資料庫自行產生,因此不需要由前端提供。
所以目前可以先把兩者理解成:
Request
↓
API Schema
↓
Validation
↓
API Logic
↓
Data Layer
↓
Database Schema
↓
Database
API Schema 負責的是「這個 API 可以接受什麼資料」,Database Schema 則負責「資料庫可以儲存什麼資料」,兩者各自有自己的責任,也不需要完全相同。
不同 API 的資料需求也不一定相同。
例如 POST /notes 建立 Note 時,title 和 content 都必須存在:
const createNoteSchema = z.object({
title: z.string().trim().min(1),
content: z.string().trim().min(1),
});
但 PATCH /notes/:id 可能只修改其中一個欄位,因此可以建立另一份 Schema:
const updateNoteSchema = createNoteSchema.partial();
這樣 PATCH 就可以只傳:
{
"title": "新的標題"
}
所以 Schema 描述的不是單純「Note 長什麼樣子」,而是:
這一種 API Request 允許什麼資料。
如果專案很小,只有少量 API,而且驗證規則也很簡單,直接使用 if 判斷完全沒有問題。
例如:
if (!name) {
return res.status(400).json({
message: "name is required",
});
}
這種情況不一定需要特別引入 Zod。比較合理的理解方式是,當規則很少時可以直接自己寫,當驗證規則開始增加,或需要在不同 API 中重複使用時,再把規則整理成 Schema,交給 Zod 處理。
一開始,我們的 Validation 和 API Logic 都放在 Route 裡:
routes/
└── notes.js
├── API Logic
└── Validation
今天則把不同責任拆開:
project/
├── app.js
│ └── 組合 Express Application
│
├── routes/
│ └── notes.js
│ └── 處理 Notes API
│
├── schemas/
│ └── note.js
│ └── 定義 API 資料規則
│
└── middlewares/
├── validate.js
│ └── 執行 Validation
│
└── errorHandler.js
└── 統一處理錯誤
一筆 POST /notes Request 的流程就會變成:
POST /notes
↓
express.json()
↓
validate(noteSchema)
↓
schemas/note.js
↓
驗證失敗 → errorHandler
↓
驗證成功
↓
routes/notes.js
↓
建立 Note
↓
Response
這樣整理之後,Route 不需要知道每個欄位怎麼驗證,Schema 不需要知道 Express 的處理流程,Middleware 也不需要知道 Note 的商業邏輯,每個部分都有比較清楚的責任。
API Validation 的目的,是確保後端收到的資料符合 API 定義的規則。一開始可以直接使用 if 判斷,當驗證規則變多之後,再把這些規則整理成 Schema,使用 Zod 執行 Validation,最後透過 Middleware 將驗證從 Route 中抽離,讓資料規則、驗證流程與 API 邏輯各自負責不同的事情。
另外,Zod 產生的是 Validation 結果,而不是 API 最終一定要回傳的格式。Zod 可以找到多個 issues,但 API 文件可能規定一次只回傳一個錯誤,因此應該由 Middleware 依照 API Contract 整理 Validation 結果,再交給 Error Handler 統一輸出。
做到這裡,Notes API 已經從單純完成 CRUD,開始具備資料驗證與清楚的責任分層。接下來,就可以讓這些資料真正被保存下來,解決 Server 重新啟動後資料消失的問題。