Zod validation: kiểm tra dữ liệu đầu vào cho API đơn hàng
Zod validation cho API Node.js: vì sao kiểu TypeScript không bảo vệ được dữ liệu gửi lên, và cách dùng schema Zod để chặn đơn hỏng, báo lỗi theo từng trường.
Zod validation là cách khai báo dữ liệu hợp lệ bằng một schema, rồi để Zod kiểm tra mọi thứ gửi lên và báo lỗi theo từng trường. Dữ liệu sai bị chặn ngay cửa. Bạn có muốn API của mình không sập chỉ vì một đơn hỏng không? Cùng mình làm nhé!
Website sắp gửi đơn tới máy chủ, ai kiểm tra nó?
Ở bài trước, mình dọn file server.ts của hệ thống đơn hàng quán Mộc theo ghi chú của anh Khoa, người review code (quán Mộc và anh Khoa là nhân vật minh họa của series này). Code dễ đọc hơn, và máy chủ nhận danh sách đơn qua một tham số thay vì tự đọc file. Đúng lúc đó, chủ quán nhắn: website của quán sắp gửi đơn thẳng tới máy chủ này bằng một request POST, thay vì đợi ai đó xuất file JSON mỗi sáng.
Dữ liệu từ file thì mình đã biết nó hỏng kiểu gì: máy bán hàng cũ ghi giá dạng chuỗi "35.000". Dữ liệu từ website thì chưa ai biết. Nó có thể thiếu trường, gửi số lượng là chữ, hay gửi một mảng món rỗng. Và nó đến từ bên ngoài máy chủ, nơi mình không kiểm soát được.
Để bạn làm theo được mà không cần mở bài trước, mình dựng lại phần cần thiết. Bạn tạo thư mục don-hang-moc với cấu trúc sau:
don-hang-moc/
package.json
src/orders.ts
src/don-mau.ts
src/order-schema.ts
src/order-server.ts
src/order-schema.test.ts
ban-ngay-tho/order-server.ts
ban-ngay-tho/thu.ts
thu-zod.tspackage.json cần Node.js 22.18 trở lên, vì từ bản đó Node chạy thẳng file .ts:
{
"name": "don-hang-moc",
"private": true,
"type": "module",
"dependencies": { "zod": "4.6.7" },
"devDependencies": { "vitest": "5.1.0" }
}File src/orders.ts là phần còn lại của các bài trước. Nó mô tả một đơn hàng, đọc giá (kể cả giá chuỗi "35.000" của máy cũ) và tính tổng tiền. Mình lược những phần bài này không dùng:
export type Customer = { name: string; phone: string };
export type OrderItem = { name: string; price: number; quantity: number };
export type Order = {
id: number;
createdAt: string;
source: "web" | "may-cu";
// Đơn từ máy bán hàng cũ (source "may-cu") bán tại quầy có thể không có khách hàng.
customer?: Customer;
items: OrderItem[];
};
// Menu của Mộc không có món nào dưới 1.000đ. Giá nhỏ hơn gần như chắc chắn là dữ liệu hỏng,
// ví dụ "35.000" bị mất dấu ngoặc kép trong JSON nên thành số 35.
export const MIN_PRICE = 1000;
// "35.000" → 35000. Định dạng lạ, hoặc giá không phải số nguyên từ MIN_PRICE trở lên, thì báo lỗi, không đoán.
export function parsePrice(raw: number | string): number {
const price = typeof raw === "number" ? raw : parsePriceText(raw);
if (!Number.isInteger(price) || price < MIN_PRICE) throw new Error(`Giá không hợp lệ: ${quoteIfText(raw)}`);
return price;
}
function parsePriceText(raw: string): number {
const text = raw.trim();
// Nhóm nghìn bằng dấu chấm. Khớp: "35.000", "1.250.000". Không khớp: "35,000", "35.00", "3500.000".
if (/^\d{1,3}(\.\d{3})+$/.test(text)) return Number(text.replaceAll(".", ""));
if (/^\d+$/.test(text)) return Number(text);
throw new Error(`Giá không hợp lệ: ${quoteIfText(raw)}`);
}
// Chuỗi trong ngoặc kép, số thì giữ nguyên, để thông báo lỗi phân biệt được "35" với 35.
const quoteIfText = (raw: number | string) => (typeof raw === "string" ? `"${raw}"` : String(raw));
export function customerLabel(order: Order): string {
if (!order.customer) return "KHÁCH TẠI QUẦY";
return order.customer.name.toUpperCase();
}
export function orderTotal(order: Order): number {
return order.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
// Danh sách cho trang quản lý: mỗi đơn một dòng gồm mã, tên khách (in hoa) và tổng tiền.
export function listOrders(orders: Order[]) {
return orders.map((order) => ({
id: order.id,
khach: customerLabel(order),
tong: orderTotal(order),
}));
}Và src/don-mau.ts là hai đơn có sẵn để thử máy chủ, một đơn từ website, một đơn tại quầy không có thông tin khách:
import type { Order } from "./orders.ts";
// Hai đơn có sẵn để thử máy chủ: một đơn từ website, một đơn tại quầy không có thông tin khách.
export const DON_MAU: Order[] = [
{
id: 1041,
createdAt: "2026-11-03T08:12:00+07:00",
source: "web",
customer: { name: "Nguyễn An", phone: "0901000001" },
items: [
{ name: "Cà phê sữa", price: 29000, quantity: 2 },
{ name: "Bánh mì trứng", price: 25000, quantity: 1 },
],
},
{
id: 1043,
createdAt: "2026-11-03T10:05:00+07:00",
source: "may-cu",
items: [{ name: "Cà phê đen", price: 25000, quantity: 1 }],
},
];Điều gì xảy ra nếu tin hoàn toàn vào dữ liệu gửi lên?
Cách đầu tiên mình nghĩ tới, và cũng là cách hầu hết ví dụ trên mạng làm, là đọc nội dung request, JSON.parse rồi nhét vào danh sách. Dưới đây là máy chủ bản đầu tiên. Nó có hai đường: POST /don-hang nhận đơn mới, GET /don-hang trả danh sách như trước:
import { createServer, type Server, type ServerResponse } from "node:http";
import { listOrders, type Order } from "../src/orders.ts";
const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" };
const TEXT_HEADERS = { "content-type": "text/plain; charset=utf-8" };
// Bản đầu tiên của POST /don-hang: tin hoàn toàn vào dữ liệu gửi lên.
export function createOrderServer(orders: Order[]): Server {
return createServer(async (request, response) => {
if (request.url === "/don-hang" && request.method === "POST") return receiveOrder(request, response, orders);
if (request.url === "/don-hang") return sendOrderList(response, orders);
response.writeHead(404).end();
});
}
async function receiveOrder(request: AsyncIterable<Buffer>, response: ServerResponse, orders: Order[]): Promise<void> {
let body = "";
for await (const chunk of request) body += chunk;
const data = JSON.parse(body);
const order = { id: Math.max(...orders.map((o) => o.id)) + 1, createdAt: new Date().toISOString(), source: "web", ...data } as Order;
orders.push(order);
response.writeHead(201, JSON_HEADERS).end(JSON.stringify({ id: order.id }));
}
function sendOrderList(response: ServerResponse, orders: Order[]): void {
try {
const orderListJson = JSON.stringify(listOrders(orders), null, 2);
response.writeHead(200, JSON_HEADERS).end(orderListJson);
} catch (error) {
console.error(error);
response.writeHead(500, TEXT_HEADERS).end("Lỗi máy chủ");
}
}Hãy chú ý dòng có as Order. Mình bảo TypeScript "tin tôi đi, đây là một đơn hàng" cho một dữ liệu mà mình chưa kiểm tra gì. TypeScript tin ngay, và code vẫn biên dịch êm. Để xem chuyện gì thật sự xảy ra, mình viết một script khởi động máy chủ này, gửi ba đơn một, trong đó có hai đơn hỏng, và sau mỗi đơn đọc lại danh sách:
// Gửi lần lượt các đơn "website" vào máy chủ bản đầu tiên, sau mỗi đơn đọc lại danh sách.
import type { AddressInfo } from "node:net";
import { DON_MAU } from "../src/don-mau.ts";
import { createOrderServer } from "./order-server.ts";
const server = createOrderServer([...DON_MAU]);
await new Promise<void>((resolve) => server.listen(0, resolve));
const goc = `http://localhost:${(server.address() as AddressInfo).port}/don-hang`;
const khach = { name: "Phạm Dung", phone: "0901000010" };
const CAC_DON: [string, unknown][] = [
["đơn đúng", { customer: khach, items: [{ name: "Cà phê sữa", price: 29000, quantity: 2 }] }],
["số lượng là chữ", { customer: khach, items: [{ name: "Cà phê sữa", price: 29000, quantity: "hai" }] }],
["khách thiếu tên", { customer: { phone: "0901000011" }, items: [{ name: "Bạc xỉu", price: 32000, quantity: 1 }] }],
];
console.error = () => {}; // tắt dòng lỗi của máy chủ để kết quả gọn
for (const [ten, don] of CAC_DON) {
const gui = await fetch(goc, { method: "POST", body: JSON.stringify(don) });
const danhSach = await fetch(goc);
const cuoi = (await danhSach.text()).split("\n").slice(-6).join(" ").replace(/\s+/g, " ");
console.log(`${ten}: POST trả ${gui.status}; sau đó GET trả ${danhSach.status}${danhSach.ok ? ` ...${cuoi}` : ""}`);
}
server.close();đơn đúng: POST trả 201; sau đó GET trả 200 ... { "id": 1044, "khach": "PHẠM DUNG", "tong": 58000 } ]
số lượng là chữ: POST trả 201; sau đó GET trả 200 ... { "id": 1045, "khach": "PHẠM DUNG", "tong": null } ]
khách thiếu tên: POST trả 201; sau đó GET trả 500Cả ba lần POST đều trả 201, tức máy chủ vui vẻ nhận hết. Nhìn dòng thứ hai: đơn 1045 có số lượng là chữ "hai", nên phép nhân price * quantity ra NaN, và JSON.stringify đổi NaN thành null. Tổng tiền của đơn hiện null trong trang quản lý, im lặng, không có lỗi nào. Dòng thứ ba tệ hơn nhiều: đơn khách thiếu tên làm customerLabel gọi toUpperCase trên undefined, và từ đó GET /don-hang trả 500 cho mọi người, mãi mãi, cho tới khi ai đó khởi động lại máy chủ và xóa đơn hỏng. Một khách gửi một đơn sai là cả trang quản lý của quán sập.
Vì sao as Order không cứu được? Kiểu TypeScript chỉ tồn tại lúc bạn viết và biên dịch code. Khi chương trình chạy, các kiểu đã biến mất, và JSON.parse trả về bất cứ thứ gì nằm trong chuỗi mà người ta gửi. Kiểu là lời hứa của bạn với trình biên dịch, không phải sự kiểm tra dữ liệu thật. Nơi duy nhất bạn kiểm tra được là chỗ dữ liệu đi vào.
Tự viết các câu if thì sao?
Phản xạ đầu tiên của nhiều người là viết các câu if: if (typeof data.customer !== "object"), if (!Array.isArray(data.items)), if (typeof item.quantity !== "number"). Mình đếm thử cho riêng đơn hàng này: khách có hai trường, mỗi món có ba trường, kèm các điều kiện như số lượng nguyên từ 1 đến 50 hoặc số điện thoại 10 chữ số. Vậy là khoảng hai chục điều kiện, mỗi điều kiện cần một câu thông báo lỗi và vị trí lỗi trong dữ liệu. Code kiểu đó dài, lặp, và khó nhìn ra luật của đơn hàng là gì giữa một đống if.
Zod cho bạn viết chính luật đó dưới dạng khai báo, như viết mô tả của dữ liệu, rồi tự lo việc kiểm tra và báo lỗi.
Viết schema Zod cho đơn hàng
Bạn cài Zod bằng npm install sau khi có package.json ở trên. Schema của đơn do website gửi lên nằm trong file này. Mình đọc từng phần ngay sau code:
import { z } from "zod";
import { parsePrice } from "./orders.ts";
// Giá có thể là số (đơn từ web) hoặc chuỗi "35.000" (máy cũ). Dùng lại parsePrice của tập 2, và biến lỗi của nó
// thành lỗi của Zod để thông báo gắn đúng vào ô "price".
const priceSchema = z.union([z.number("Giá phải là số hoặc chuỗi"), z.string("Giá phải là số hoặc chuỗi")]).transform((raw, ctx) => {
try {
return parsePrice(raw);
} catch (error) {
ctx.issues.push({ code: "custom", message: (error as Error).message, input: raw });
return z.NEVER;
}
});
// Thông báo tiếng Việt cho hai lỗi mà Zod tự phát hiện ở mức đối tượng: trường lạ và sai kiểu. Lỗi khác dùng thông báo mặc định.
const objectError = (issue: { code: string; keys?: string[] }) => {
if (issue.code === "unrecognized_keys") return `Không nhận trường: ${issue.keys?.join(", ")}`;
if (issue.code === "invalid_type") return "Phải là một đối tượng JSON";
return undefined;
};
const customerSchema = z.strictObject({
name: z.string("Tên khách phải là chữ").trim().min(1, "Tên khách không được để trống").max(80, "Tên khách tối đa 80 ký tự"),
phone: z.string("Số điện thoại phải là chữ").regex(/^0\d{9}$/, "Số điện thoại gồm 10 chữ số, bắt đầu bằng 0"),
}, { error: objectError });
const itemSchema = z.strictObject({
name: z.string("Tên món phải là chữ").trim().min(1, "Tên món không được để trống"),
price: priceSchema,
quantity: z.number("Số lượng phải là số").int("Số lượng phải là số nguyên").min(1, "Số lượng tối thiểu là 1").max(50, "Số lượng tối đa là 50"),
}, { error: objectError });
// Đơn do website gửi lên. strictObject từ chối trường lạ: website không được tự đặt id, source hay createdAt.
export const newOrderSchema = z.strictObject({
customer: customerSchema,
items: z.array(itemSchema, "Danh sách món phải là một mảng").min(1, "Đơn phải có ít nhất một món").max(20, "Một đơn có tối đa 20 món"),
}, { error: objectError });
// Kiểu TypeScript suy ra từ schema: không phải viết hai lần.
export type NewOrder = z.infer<typeof newOrderSchema>;
// Danh sách lỗi cho người gọi API: mỗi lỗi một dòng, nói rõ trường nào và sai gì.
export function listIssues(error: z.ZodError): { truong: string; loi: string }[] {
return error.issues.map((issue) => ({ truong: issue.path.join(".") || "(cả đơn)", loi: issue.message }));
}Mình giải thích các quyết định, vì mỗi cái có lý do:
z.strictObject thay vì z.object. Mặc định Zod bỏ qua trường lạ trong dữ liệu, tức website gửi thêm "id": 1 thì Zod lặng lẽ cắt đi. Mình chọn từ chối hẳn. Nếu website đang cố tự đặt id hay source, đó là dấu hiệu có gì đó sai ở phía gửi, và thà báo lỗi rõ ràng còn hơn im lặng cắt đi rồi không ai biết. Đây là một lựa chọn, bạn có thể chọn z.object nếu muốn dễ dãi với phiên bản website mới thêm trường.
.transform cho giá. Hàm parsePrice từ các bài trước đã biết đọc "35.000" và từ chối "35,000", nên mình dùng lại thay vì viết luật thứ hai. Khi nó ném lỗi, mình đưa lỗi đó vào danh sách lỗi của Zod qua ctx.issues.push, để thông báo "Giá không hợp lệ: 35" gắn đúng vào ô price của món đó. Giá trị trả về là số, nên sau khi qua schema, giá luôn là số.
.trim() ở tên. Zod cắt khoảng trắng thừa trước khi kiểm tra độ dài, nên tên chỉ có dấu cách bị coi là rỗng.
z.infer. Kiểu NewOrder được suy ra từ chính schema, nên bạn không phải viết một kiểu TypeScript rồi viết thêm một bộ kiểm tra, hai thứ này sớm muộn sẽ lệch nhau.
Hai hàm z.number("...") và z.string("...") nhận một chuỗi, đó là thông báo khi dữ liệu thiếu hoặc sai kiểu. Với Zod 4, chuỗi truyền vào đây được dùng làm thông báo lỗi. Hàm objectError chỉ để dịch hai lỗi mà Zod tự đặt tên bằng tiếng Anh ở mức cả đối tượng, như Unrecognized key.
Nối schema vào máy chủ
Giờ thay as Order bằng safeParse. Hàm này không ném lỗi, nó trả về một đối tượng cho biết thành công hay không, kèm dữ liệu hoặc danh sách lỗi:
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { listIssues, newOrderSchema } from "./order-schema.ts";
import { listOrders, type Order } from "./orders.ts";
const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" };
const TEXT_HEADERS = { "content-type": "text/plain; charset=utf-8" };
const MAX_BODY_BYTES = 100_000;
// Máy chủ HTTP cho đơn hàng. Nhận đơn đã chuẩn hóa, không tự đọc file, không tự mở cổng.
export function createOrderServer(orders: Order[]): Server {
return createServer(async (request, response) => {
if (request.url === "/don-hang" && request.method === "POST") return receiveOrder(request, response, orders);
if (request.url === "/don-hang") return sendOrderList(response, orders);
response.writeHead(404).end();
});
}
async function receiveOrder(request: IncomingMessage, response: ServerResponse, orders: Order[]): Promise<void> {
const body = await readBody(request);
if (body === null) return sendError(response, 413, "Nội dung gửi lên quá lớn");
let data: unknown;
try {
data = JSON.parse(body);
} catch {
return sendError(response, 400, "Nội dung gửi lên không phải JSON hợp lệ");
}
const result = newOrderSchema.safeParse(data);
if (!result.success) return sendError(response, 400, "Đơn hàng không hợp lệ", listIssues(result.error));
const order: Order = {
id: Math.max(0, ...orders.map((existing) => existing.id)) + 1,
createdAt: new Date().toISOString(),
source: "web",
...result.data,
};
orders.push(order);
response.writeHead(201, JSON_HEADERS).end(JSON.stringify({ id: order.id }));
}
// Đọc tối đa MAX_BODY_BYTES; vượt quá thì trả null để bên gọi từ chối.
async function readBody(request: IncomingMessage): Promise<string | null> {
let body = "";
for await (const chunk of request) {
body += chunk;
if (body.length > MAX_BODY_BYTES) return null;
}
return body;
}
function sendError(response: ServerResponse, status: number, message: string, details?: unknown): void {
response.writeHead(status, JSON_HEADERS).end(JSON.stringify({ loi: message, ...(details ? { chiTiet: details } : {}) }));
}
function sendOrderList(response: ServerResponse, orders: Order[]): void {
try {
const orderListJson = JSON.stringify(listOrders(orders), null, 2);
response.writeHead(200, JSON_HEADERS).end(orderListJson);
} catch (error) {
console.error(error);
response.writeHead(500, TEXT_HEADERS).end("Lỗi máy chủ");
}
}Chuỗi xử lý có ba cổng, và mình bố trí từ rẻ đến đắt. Cổng đầu là kích thước: nội dung quá 100.000 ký tự thì từ chối ngay bằng mã 413, trước khi tốn công phân tích. Cổng hai là cú pháp: nếu không phải JSON thì trả 400. Cổng ba là nghĩa của dữ liệu, tức schema Zod. Chỉ khi qua cả ba cổng thì đơn mới được tạo, với id, createdAt, source do máy chủ đặt chứ không do website. Biến data được khai báo kiểu unknown, không phải any: TypeScript buộc bạn kiểm tra nó trước khi dùng, và safeParse chính là bước kiểm tra đó. Sau result.success, kiểu của result.data là NewOrder, đúng với những gì thực sự có.
Mã trạng thái 400 báo "bạn gửi sai", còn 5xx là "máy chủ có lỗi". Phân biệt hai loại này quan trọng, vì bên gọi cần biết gửi lại có ích hay không, và vì bạn không muốn đơn hỏng của khách lọt vào thống kê lỗi của máy chủ.
Để thấy kết quả, script này gửi năm đơn vào máy chủ mới, bốn đơn hỏng và một đơn đúng, rồi đọc lại danh sách:
// Gửi các đơn hỏng vào máy chủ đã có Zod và in lỗi trả về. Chạy: node thu-zod.ts
import type { AddressInfo } from "node:net";
import { DON_MAU } from "./src/don-mau.ts";
import { createOrderServer } from "./src/order-server.ts";
const server = createOrderServer([...DON_MAU]);
await new Promise<void>((resolve) => server.listen(0, resolve));
const goc = `http://localhost:${(server.address() as AddressInfo).port}/don-hang`;
const khach = { name: "Phạm Dung", phone: "0901000010" };
const CAC_DON: [string, string][] = [
["đơn đúng", JSON.stringify({ customer: khach, items: [{ name: "Cà phê sữa", price: "29.000", quantity: 2 }] })],
["số lượng là chữ", JSON.stringify({ customer: khach, items: [{ name: "Cà phê sữa", price: 29000, quantity: "hai" }] })],
["khách thiếu tên, giá 35", JSON.stringify({ customer: { phone: "0901000011" }, items: [{ name: "Bạc xỉu", price: 35, quantity: 1 }] })],
["thiếu items, thừa trường id", JSON.stringify({ id: 1, customer: khach })],
["không phải JSON", "{customer:"],
];
for (const [ten, noiDung] of CAC_DON) {
const phanHoi = await fetch(goc, { method: "POST", body: noiDung });
console.log(`${ten}: ${phanHoi.status} ${await phanHoi.text()}`);
}
console.log(`danh sách sau đó: ${(await fetch(goc)).status}`);
server.close();đơn đúng: 201 {"id":1044}
số lượng là chữ: 400 {"loi":"Đơn hàng không hợp lệ","chiTiet":[{"truong":"items.0.quantity","loi":"Số lượng phải là số"}]}
khách thiếu tên, giá 35: 400 {"loi":"Đơn hàng không hợp lệ","chiTiet":[{"truong":"customer.name","loi":"Tên khách phải là chữ"},{"truong":"items.0.price","loi":"Giá không hợp lệ: 35"}]}
thiếu items, thừa trường id: 400 {"loi":"Đơn hàng không hợp lệ","chiTiet":[{"truong":"items","loi":"Danh sách món phải là một mảng"},{"truong":"(cả đơn)","loi":"Không nhận trường: id"}]}
không phải JSON: 400 {"loi":"Nội dung gửi lên không phải JSON hợp lệ"}
danh sách sau đó: 200Đơn đúng được nhận, và dù website gửi giá là chuỗi "29.000", đơn vẫn vào với giá số, nhờ transform. Mỗi đơn hỏng bị từ chối với mã 400 và một danh sách lỗi chỉ đúng chỗ sai. Đơn thứ ba có hai lỗi và Zod báo cả hai cùng lúc, nên người gọi sửa một lần là xong, khỏi phải gửi đi gửi lại để phát hiện lỗi từng cái. Dòng cuối cho thấy điều quan trọng nhất: danh sách đơn vẫn trả 200, không đơn hỏng nào lọt vào hệ thống.
Viết test cho schema
Một schema cũng là code, và nó là chỗ quyết định dữ liệu nào được vào. Mình viết test cho nó, với mỗi trường hợp sai ghi rõ lỗi mong đợi. Cách này giống test của parsePrice ở các bài trước: một danh sách đầu vào kèm lý do:
import { describe, expect, test } from "vitest";
import { listIssues, newOrderSchema } from "./order-schema.ts";
const customer = { name: "Phạm Dung", phone: "0901000010" };
const validItem = { name: "Cà phê sữa", price: 29000, quantity: 2 };
// Chạy schema và trả về danh sách lỗi dạng "trường: thông báo", để test đọc như đọc câu.
function errorsOf(input: unknown): string[] {
const result = newOrderSchema.safeParse(input);
return result.success ? [] : listIssues(result.error).map((issue) => `${issue.truong}: ${issue.loi}`);
}
describe("newOrderSchema", () => {
test("nhận đơn đúng và giữ nguyên dữ liệu", () => {
expect(newOrderSchema.parse({ customer, items: [validItem] })).toEqual({ customer, items: [validItem] });
});
test("giá dạng chuỗi của máy cũ được đổi thành số", () => {
const order = newOrderSchema.parse({ customer, items: [{ ...validItem, price: "35.000" }] });
expect(order.items[0].price).toBe(35000);
});
test("cắt khoảng trắng thừa ở tên khách", () => {
expect(newOrderSchema.parse({ customer: { ...customer, name: " An " }, items: [validItem] }).customer.name).toBe("An");
});
test.each([
["số lượng là chữ", { customer, items: [{ ...validItem, quantity: "hai" }] }, "items.0.quantity: Số lượng phải là số"],
["số lượng lẻ", { customer, items: [{ ...validItem, quantity: 1.5 }] }, "items.0.quantity: Số lượng phải là số nguyên"],
["số lượng bằng 0", { customer, items: [{ ...validItem, quantity: 0 }] }, "items.0.quantity: Số lượng tối thiểu là 1"],
["giá 35 (mất ngoặc kép)", { customer, items: [{ ...validItem, price: 35 }] }, "items.0.price: Giá không hợp lệ: 35"],
["khách thiếu tên", { customer: { phone: "0901000010" }, items: [validItem] }, "customer.name: Tên khách phải là chữ"],
["số điện thoại ngắn", { customer: { ...customer, phone: "090100" }, items: [validItem] }, "customer.phone: Số điện thoại gồm 10 chữ số, bắt đầu bằng 0"],
["thiếu items", { customer }, "items: Danh sách món phải là một mảng"],
["items rỗng", { customer, items: [] }, "items: Đơn phải có ít nhất một món"],
["trường lạ id", { id: 1, customer, items: [validItem] }, "(cả đơn): Không nhận trường: id"],
["cả đơn không phải đối tượng", [], "(cả đơn): Phải là một đối tượng JSON"],
])("từ chối đơn có %s", (_ten, input, expectedError) => {
expect(errorsOf(input)).toEqual([expectedError]);
});
test("báo cùng lúc mọi lỗi, không dừng ở lỗi đầu tiên", () => {
const errors = errorsOf({ customer: { phone: "1" }, items: [{ ...validItem, quantity: 0 }] });
expect(errors).toHaveLength(3);
});
}); Test Files 1 passed (1)
Tests 14 passed (14)Mình chỉ chép hai dòng tổng kết của Vitest, bỏ các dòng tiêu đề và thời gian chạy.
Trong repo demo, mình còn viết thêm bốn test cho chính đường POST /don-hang: đơn đúng được thêm vào danh sách với id kế tiếp, đơn sai trả 400 mà danh sách không đổi, nội dung không phải JSON trả 400, nội dung quá lớn trả 413. Mình không dán hết ở đây cho bài khỏi quá dài, nhưng khi bạn tự làm, đó là bốn chỗ nên có test. Chạy toàn bộ cùng các test cũ của các bài trước thì được 40 test xanh.
Khi lỗi xảy ra lúc không ai nhìn
Máy chủ của quán Mộc giờ không còn sập vì một đơn hỏng. Đơn sai bị từ chối ngay, kèm lời giải thích cho người gửi. Nhưng một điều vẫn còn nguyên: khi có chuyện gì đó xảy ra, ví dụ website liên tục gửi sai cùng một kiểu lúc hai giờ sáng, thì không ai ở quán biết cả. Người gửi nhận 400, máy chủ im lặng, và sáng ra chủ quán chỉ thấy đơn không về. Làm sao để máy chủ ghi lại chuyện gì đã xảy ra, đủ để người đến sau tìm được nguyên nhân, là việc của các bài viết sau.
Bạn theo dõi cả câu chuyện ở series Nhận dự án cũ. Cách khai báo schema của Zod có đầy đủ trong tài liệu chính thức của Zod. Nếu đang ôn phỏng vấn, các câu hỏi về TypeScript và API có trong ngân hàng câu hỏi phỏng vấn.