Validate dữ liệu runtime trong TypeScript với Zod
1. Tổng quan
Type của TypeScript bị xóa sau khi biên dịch. Vì vậy chúng không bảo vệ bạn khỏi JSON sai định dạng từ API, form do người dùng gửi lên hay biến môi trường thiếu giá trị.
Bài này hướng dẫn dùng Zod 4 để validate dữ liệu ở runtime. Bạn sẽ khai báo schema một lần và suy ra type từ đó. Bạn cũng sẽ biết cách xử lý lỗi gọn gàng, viết test và tránh vài cái bẫy hay gặp.
2. Vì sao type TypeScript không đủ ở runtime
Xét đoạn code quen thuộc sau:
interface Customer {
id: number;
email: string;
}
const raw = '{"id": "42", "email": null}';
const customer = JSON.parse(raw) as Customer;
console.log(customer.id + 1);
console.log(customer.email.toLowerCase());
JSON.parse trả về any. Lệnh as Customer chỉ là lời hứa với compiler, không có bước kiểm tra thật nào. Kết quả chạy:
421
TypeError: Cannot read properties of null (reading 'toLowerCase')
id là chuỗi "42" nên phép cộng thành nối chuỗi. email là null nên chương trình crash. Compiler không báo gì cả. Bạn cần một lớp kiểm tra chạy thật ở runtime. Zod làm đúng việc đó.
3. Cài đặt
Zod không có dependency nào khác và chạy được trên cả Node.js lẫn trình duyệt:
npm install zod
Để Zod suy ra type chính xác, bạn cần bật strict mode trong tsconfig.json:
{
"compilerOptions": {
"strict": true
}
}
Lưu ý: Thiếu
"strict": truethì các field optional có thể bị suy ra sai. Bạn sẽ mất phần lớn lợi ích về type.
4. Định nghĩa schema và suy ra type
Schema mô tả hình dạng dữ liệu mà bạn mong đợi:
import { z } from "zod";
const CustomerSchema = z.object({
id: z.number().int().positive(),
email: z.email(),
fullName: z.string().min(2).max(100),
phone: z.string().optional(),
tier: z.enum(["FREE", "PRO", "ENTERPRISE"]).default("FREE"),
});
type Customer = z.infer<typeof CustomerSchema>;
Từng phần làm việc sau:
z.number().int().positive()yêu cầu một số nguyên dương.z.email()kiểm tra chuỗi có đúng định dạng email không. Trong Zod 4, các định dạng chuỗi như email, URL, UUID được khai báo ở cấp cao nhất..optional()cho phép field vắng mặt hoặc mang giá trịundefined..default("FREE")tự điền giá trị khi input không có fieldtier.z.infersinh type TypeScript từ schema.
Khi hover vào Customer, bạn sẽ thấy type tương đương:
type Customer = {
id: number;
email: string;
fullName: string;
phone?: string | undefined;
tier: "FREE" | "PRO" | "ENTERPRISE";
}
Điểm mạnh nằm ở đây: schema là nguồn sự thật duy nhất. Khi bạn thêm field vào schema, type cập nhật theo. Bạn không phải duy trì một interface song song.
5. parse và safeParse
Zod có hai cách chạy validate chính.
5.1. parse: ném exception khi sai
const customer = CustomerSchema.parse({
id: 42,
email: "an.nguyen@example.com",
fullName: "Nguyễn Văn An",
});
console.log(customer);
{ id: 42, email: 'an.nguyen@example.com', fullName: 'Nguyễn Văn An', tier: 'FREE' }
Giá trị trả về đã được kiểm tra và có type Customer. Field tier được điền mặc định. Nếu dữ liệu sai, parse ném ZodError.
5.2. safeParse: trả về kết quả, không ném lỗi
const result = CustomerSchema.safeParse({ id: "42", email: "not-an-email" });
if (!result.success) {
console.log(result.error.issues.map((i) => `${i.path.join(".")}: ${i.code}`));
} else {
console.log(result.data.email);
}
[ 'id: invalid_type', 'email: invalid_format', 'fullName: invalid_type' ]
result là một discriminated union. Sau khi bạn kiểm tra result.success, TypeScript biết chắc nhánh nào có data và nhánh nào có error. Bạn cũng thấy Zod không dừng ở lỗi đầu tiên mà gom đủ mọi lỗi. Điều này rất tiện khi hiển thị lỗi cho form.
Quy tắc chọn đơn giản:
- Dùng
safeParseở ranh giới hệ thống, ví dụ request body hay form, nơi lỗi là chuyện bình thường. - Dùng
parsekhi dữ liệu sai nghĩa là bug hoặc cấu hình hỏng và ứng dụng nên dừng ngay.
6. Thông báo lỗi tùy chỉnh và định dạng lỗi
Thông báo mặc định của Zod viết bằng tiếng Anh và khá kỹ thuật. Bạn có thể truyền tham số error để thay thế:
const SignUpSchema = z.object({
email: z.email({ error: "Email không hợp lệ" }),
password: z.string().min(8, { error: "Mật khẩu tối thiểu 8 ký tự" }),
});
const result = SignUpSchema.safeParse({ email: "abc", password: "123" });
if (!result.success) {
console.log(z.flattenError(result.error));
}
{
formErrors: [],
fieldErrors: {
email: [ 'Email không hợp lệ' ],
password: [ 'Mật khẩu tối thiểu 8 ký tự' ]
}
}
z.flattenError gom lỗi theo tên field. Định dạng này hợp để trả về frontend. Còn khi cần log cho người đọc, bạn dùng z.prettifyError(result.error) để có chuỗi nhiều dòng dễ đọc.
7. Coerce, transform và refine
Dữ liệu thực tế hiếm khi khớp hoàn hảo với schema. Zod có ba công cụ để xử lý tình huống này.
7.1. z.coerce: ép kiểu đầu vào
Query string và biến môi trường luôn là chuỗi. z.coerce chuyển đổi chúng trước khi validate:
const PaginationSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
size: z.coerce.number().int().max(100).default(20),
});
console.log(PaginationSchema.parse({ page: "3", size: "50" }));
{ page: 3, size: 50 }
Lưu ý:
z.coerce.number()gọiNumber(input). Vì vậy chuỗi rỗng""thành0và vượt qua kiểm tra kiểu. Luôn thêm ràng buộc như.min(1)để chặn trường hợp này.
7.2. transform: biến đổi sau khi validate
const OrderCodeSchema = z
.string()
.trim()
.regex(/^ord-\d{6}$/i, { error: "Mã đơn không đúng định dạng" })
.transform((code) => code.toUpperCase());
console.log(OrderCodeSchema.parse(" ord-000123 "));
ORD-000123
Sau khi có transform, type đầu vào và đầu ra có thể khác nhau. Bạn dùng z.input<typeof S> và z.output<typeof S> để lấy riêng từng loại. z.infer tương đương với z.output.
7.3. refine: luật nghiệp vụ tùy ý
Có những ràng buộc liên quan đến nhiều field cùng lúc. Bạn đặt refine ở cấp object để xử lý:
const BookingSchema = z
.object({
checkIn: z.coerce.date(),
checkOut: z.coerce.date(),
})
.refine((b) => b.checkOut > b.checkIn, {
error: "Ngày trả phòng phải sau ngày nhận phòng",
path: ["checkOut"],
});
const r = BookingSchema.safeParse({ checkIn: "2026-11-10", checkOut: "2026-11-08" });
console.log(r.success, r.error?.issues[0].path, r.error?.issues[0].message);
false [ 'checkOut' ] Ngày trả phòng phải sau ngày nhận phòng
path gắn lỗi vào field checkOut thay vì gắn vào cả object. Nhờ vậy frontend hiển thị lỗi đúng chỗ.
8. Ví dụ thực tế
8.1. Validate request tạo đơn hàng
Ví dụ dưới đây là một hàm xử lý request, không phụ thuộc framework. Bạn dùng được nó với Express, Fastify hay Next.js:
import { z } from "zod";
const OrderItemSchema = z.object({
productId: z.uuid(),
quantity: z.number().int().min(1).max(99),
});
export const CreateOrderSchema = z.object({
customerId: z.number().int().positive(),
items: z.array(OrderItemSchema).nonempty({ error: "Đơn hàng phải có ít nhất 1 sản phẩm" }),
note: z.string().max(500).optional(),
});
export type CreateOrderRequest = z.infer<typeof CreateOrderSchema>;
export function handleCreateOrder(body: unknown) {
const result = CreateOrderSchema.safeParse(body);
if (!result.success) {
return { status: 400, body: z.flattenError(result.error).fieldErrors };
}
const order: CreateOrderRequest = result.data;
return { status: 201, body: { itemCount: order.items.length } };
}
Bạn để ý tham số body có kiểu unknown, không phải any. Nhờ đó TypeScript buộc bạn validate trước khi dùng. Sau safeParse, result.data có type đầy đủ. Mặc định z.object cũng loại bỏ các field lạ, nên client không thể “nhét” thêm isAdmin: true vào object của bạn.
8.2. Validate biến môi trường khi khởi động
Ứng dụng nên dừng ngay khi cấu hình sai, đừng để đến lúc xử lý request đầu tiên mới phát hiện:
import { z } from "zod";
const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]),
PORT: z.coerce.number().int().default(3000),
DATABASE_URL: z.url(),
});
export const env = EnvSchema.parse(process.env);
Giả sử thiếu DATABASE_URL. Ứng dụng sẽ ném ZodError ngay lúc import module, với thông báo chỉ rõ DATABASE_URL bị thiếu. Ở mọi nơi khác, env.PORT có kiểu number chứ không phải string | undefined.
9. Viết test với Vitest
Schema chứa luật nghiệp vụ nên cũng cần test. Đầu tiên bạn cài Vitest:
npm install -D vitest
Sau đó viết test cho CreateOrderSchema:
import { describe, it, expect } from "vitest";
import { z } from "zod";
import { CreateOrderSchema } from "./order";
describe("CreateOrderSchema", () => {
const validItem = { productId: "3f1c2a9e-8b4d-4c1a-9f2e-1a2b3c4d5e6f", quantity: 2 };
it("chấp nhận đơn hợp lệ và bỏ field lạ", () => {
const result = CreateOrderSchema.safeParse({
customerId: 7,
items: [validItem],
isAdmin: true,
});
expect(result.success).toBe(true);
expect(result.data).toEqual({ customerId: 7, items: [validItem] });
});
it("từ chối đơn không có sản phẩm", () => {
const result = CreateOrderSchema.safeParse({ customerId: 7, items: [] });
expect(result.success).toBe(false);
expect(z.flattenError(result.error!).fieldErrors.items).toContain(
"Đơn hàng phải có ít nhất 1 sản phẩm",
);
});
it("từ chối số lượng vượt giới hạn", () => {
const result = CreateOrderSchema.safeParse({
customerId: 7,
items: [{ ...validItem, quantity: 100 }],
});
expect(result.error?.issues[0].path).toEqual(["items", 0, "quantity"]);
});
});
Chạy npx vitest run và bạn sẽ thấy cả ba test đều pass. Test thứ ba cho thấy path của lỗi đi vào tận phần tử trong mảng. Đây chính là thông tin frontend cần để tô đỏ đúng ô nhập liệu.
10. Lỗi hay gặp và khi nào không nên dùng
Ép kiểu bằng as sau khi đã có schema. Viết body as CreateOrderRequest sẽ phá vỡ toàn bộ cơ chế bảo vệ. Hãy luôn lấy dữ liệu từ result.data.
Dùng refine bất đồng bộ với parse. Ví dụ bạn kiểm tra email đã tồn tại trong database. Khi có refine async, bạn phải gọi parseAsync hoặc safeParseAsync. Nếu không, Zod sẽ ném lỗi.
Mong đợi field lạ bị từ chối. z.object mặc định âm thầm bỏ field lạ. Nếu bạn muốn báo lỗi khi có field thừa, ví dụ để bắt lỗi chính tả trong file cấu hình, hãy dùng z.strictObject(...).
Validate lặp đi lặp lại bên trong ứng dụng. Bạn chỉ nên validate ở ranh giới hệ thống: request, response từ API bên thứ ba, message queue, file cấu hình. Dữ liệu đã qua kiểm tra thì tin tưởng type của nó. Parse lại ở mỗi hàm chỉ tốn CPU.
Lưu ý: Đừng dùng Zod trong vòng lặp nóng, nơi phải xử lý hàng trăm nghìn object mỗi giây. Validate có chi phí thật. Ở những chỗ đó, hãy kiểm tra một lần ở đầu vào hoặc chọn thư viện biên dịch schema thành code.
Khai báo schema bên trong hàm. Nếu bạn tạo schema mỗi lần hàm được gọi, ứng dụng sẽ tốn thêm chi phí khởi tạo. Hãy khai báo schema ở cấp module và tái sử dụng.
11. Kết luận
Zod lấp khoảng trống giữa type ở compile time và dữ liệu thật ở runtime. Bạn khai báo schema một lần, suy ra type bằng z.infer, rồi dùng safeParse ở ranh giới hệ thống để nhận về dữ liệu sạch hoặc danh sách lỗi có cấu trúc.
Với coerce, transform và refine, bạn xử lý được hầu hết tình huống thực tế: query string, chuẩn hóa dữ liệu, luật nghiệp vụ nhiều field. Hãy validate sớm, validate ở rìa, và để TypeScript lo phần còn lại.