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": true thì 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ó field tier.
  • z.infer sinh 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 parse khi 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ọi Number(input). Vì vậy chuỗi rỗng "" thành 0 và 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.

typescriptzodvalidationnodejs