Home / Frontend / Zod: Validate Dữ Liệu Type-Safe Trong React
Frontend

Zod: Validate Dữ Liệu Type-Safe Trong React

Học Zod cho React: z.infer, parse/safeParse, coerce, optional, default và type guard validate dữ liệu thực tế

App crash ở production: user.age.toFixed(2) báo lỗi vì API trả về { age: "30" } thay vì { age: 30 }. TypeScript im lặng — nó chỉ kiểm tra type lúc compile time, còn dữ liệu từ API, form hay localStorage thì tới runtime mới lộ mặt. Viết const user: User = await res.json() là bạn đang tin, không phải đang kiểm tra.

Zod vá đúng chỗ đó: định nghĩa schema một lần, validate lúc runtime, và để TypeScript type derive thẳng từ schema — không còn cảnh type một nơi, validation rule một nẻo.

1. Zod là gì và dùng khi nào

Zod là thư viện validation thiết kế theo hướng TypeScript-first: bạn mô tả dữ liệu bằng schema, và type được derive tự động từ schema đó thay vì khai báo song song hai bản.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { z } from "zod";

// Schema là source of truth duy nhất
const userSchema = z.object({
  id: z.uuid(),
  name: z.string().min(1).max(100),
  age: z.number().int().positive(),
  email: z.email(),
  role: z.enum(["admin", "editor", "viewer"]),
});

// Type tự động — không cần viết lại thủ công
type User = z.infer<typeof userSchema>;
// → { id: string; name: string; age: number; email: string; role: "admin" | "editor" | "viewer" }

Lưu ý phiên bản

Bài viết dùng Zod 4. Nếu bạn thấy z.string().email() hay z.string().uuid() ở đâu đó — đó là spelling của Zod 3, đã deprecated trong Zod 4 và sẽ bị xóa ở major kế tiếp. Hãy dùng dạng top-level: z.email(), z.uuid(), z.url().

Ba chỗ đáng validate nhất — cũng là ba chỗ dữ liệu hay “nói dối” nhất:

  1. Form — validate input trước khi submit
  2. API response — dữ liệu từ service bên ngoài, bạn không kiểm soát được
  3. localStorage — data cũ, sai format, hoặc bị người dùng sửa tay

2. z.infer — Derive Type Từ Schema

Không có z.infer, bạn phải mô tả cùng một dữ liệu hai lần — một bản cho runtime, một bản cho compile time:

1
2
3
4
5
6
7
8
9
10
11
12
// ❌ Hai source of truth — sớm muộn gì cũng out-of-sync
type Post = {
  id: number;
  title: string;
  content: string;
};

const postSchema = z.object({
  id: z.number(),
  title: z.string(),
  content: z.string(),
});

Vấn đề không nằm ở chỗ gõ thừa vài dòng. Vấn đề là sáu tháng sau, ai đó thêm published vào schema mà quên thêm vào type Post — TypeScript vẫn xanh, và bug chỉ lộ ra lúc runtime. z.infer xoá hẳn khả năng đó bằng cách rút type thẳng ra từ schema:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const postSchema = z.object({
  id: z.number(),
  title: z.string(),
  content: z.string(),
  published: z.boolean(),
  tags: z.array(z.string()),
  author: z.object({
    id: z.string(),
    name: z.string(),
  }),
});

// Schema là source of truth duy nhất
type Post = z.infer<typeof postSchema>;
// → { id: number; title: string; content: string; published: boolean;
//     tags: string[]; author: { id: string; name: string } }

Thêm field vào postSchema, Post tự cập nhật theo. Không còn chuyện out-of-sync, vì đơn giản là không còn bản thứ hai để lệch.

Type derive ra dùng như mọi type khác trong React:

1
2
3
4
5
6
7
8
9
function PostCard({ post }: { post: Post }) {
  return (
    <article>
      <h2>{post.title}</h2>
      <p>{post.content}</p>
      {post.published ? null : <span>Bản nháp</span>}
    </article>
  );
}

Lưu ý: z.infer là type sau khi parse

z.infer trả về output type — tức dữ liệu sau khi Zod xử lý xong, chứ không phải dữ liệu bạn đưa vào. Với schema thường thì hai cái trùng nhau, nhưng khi có .default() hay coerce (mục 5) thì chúng khác nhau:

1
2
3
4
5
6
const settingsSchema = z.object({
  theme: z.string().default("light"),
});

type SettingsIn = z.input<typeof settingsSchema>; // { theme?: string | undefined }
type SettingsOut = z.infer<typeof settingsSchema>; // { theme: string }

Khi khai báo props cho component nhận data đã validate, z.infer là cái bạn muốn. Còn khi type cho giá trị trước khi parse — ví dụ form state thô — hãy dùng z.input.

3. satisfies z.ZodType — Đảm Bảo Schema Khớp Type

Mục 2 giả định schema có trước, type derive theo sau. Nhưng đôi khi mũi tên chỉ ngược lại: type đã tồn tại và bạn không được phép đổi — nó sinh ra từ OpenAPI codegen, từ package dùng chung, hay đơn giản là đã nằm sẵn trong codebase. Lúc này bạn cần điều ngược lại: bắt schema phải khớp type.

satisfies z.ZodType<T> làm đúng việc đó — TypeScript kiểm tra schema có validate ra đúng T không, mà vẫn giữ nguyên type cụ thể của schema (bạn không mất .parse(), .shape, v.v.):

1
2
3
4
5
6
7
8
9
10
11
12
// Type có sẵn — không sửa được
type Post = {
  id: number;
  title: string;
  content: string;
};

const postSchema = z.object({
  id: z.number(),
  title: z.string(),
  content: z.string(),
}) satisfies z.ZodType<Post>;

Viết sai schema, TypeScript báo ngay lúc compile time chứ không đợi tới runtime:

1
2
3
4
5
6
7
8
9
10
11
12
// ❌ Sai type: Post.id là number
z.object({
  id: z.string(),
  title: z.string(),
  content: z.string(),
}) satisfies z.ZodType<Post>;

// ❌ Thiếu field: Post yêu cầu content
z.object({
  id: z.number(),
  title: z.string(),
}) satisfies z.ZodType<Post>;

Lưu ý: field thừa thì lọt lưới

satisfies chỉ đảm bảo schema validate đủ những gì Post yêu cầu — nó không chặn field dư:

1
2
3
4
5
6
7
// ✅ Compile bình thường, dù Post không hề có `draft`
z.object({
  id: z.number(),
  title: z.string(),
  content: z.string(),
  draft: z.boolean(), // ← thừa, nhưng TypeScript không phàn nàn
}) satisfies z.ZodType<Post>;

Lý do là structural typing: object có thừa property vẫn gán được cho Post. Nếu cần chặn cả chiều này, dùng z.infer và để schema làm source of truth thay vì duy trì hai bản.

Chọn cái nào:

Tình huốngDùng
Schema là source of truth, type derive raz.infer<typeof schema>
Type có sẵn, không đổi được (codegen, shared package)satisfies z.ZodType<T>

Mặc định nên chọn z.infer — chỉ còn một bản mô tả dữ liệu, khỏi lo lệch. satisfies là lối thoát cho những lúc bạn không có quyền quyết định type.

4. parse() vs safeParse()

Hai method này validate y hệt nhau. Khác biệt duy nhất là ai chịu trách nhiệm khi data sai: parse() throw và đẩy việc đó lên trên, safeParse() trả kết quả về tay bạn.

1
2
3
4
5
6
7
8
9
10
11
12
13
const numberSchema = z.number().positive();

// parse() — trả thẳng giá trị đã validate, throw ZodError nếu sai
const value = numberSchema.parse(5); // 5

// safeParse() — không bao giờ throw, trả về result object
const result = numberSchema.safeParse(-5);

if (result.success) {
  console.log(result.data); // TypeScript biết đây là number
} else {
  console.error(result.error.issues);
}

resultdiscriminated union trên field success, nên sau khi check if (result.success) thì TypeScript tự narrow: nhánh trueresult.data, nhánh falseresult.error. Không cần cast, và cũng không thể lỡ tay đọc data khi validate fail — TypeScript chặn từ compile time.

Lưu ý: catch không biết đó là ZodError

Đây là chỗ dễ sai nhất khi dùng parse(). TypeScript luôn gán type unknown cho biến trong catch, nên bạn phải narrow trước khi chạm vào .issues:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Nhắc lại schema từ ví dụ trước (rút gọn)
const userSchema = z.object({
  id: z.string(),
  name: z.string().min(1),
  age: z.number().int().positive(),
});

const json: unknown = { id: "u1", name: "Alice", age: "30" }; // age là string — sai type

try {
  const user = userSchema.parse(json);
  console.log("Parse OK:", user);
} catch (err) {
  // ❌ err là unknown — không có .issues để đọc
  // ✅ narrow trước đã
  if (err instanceof z.ZodError) {
    console.error(err.issues);
  } else {
    throw err; // lỗi khác (network, bug...) thì để nó lan lên
  }
}

Nuốt trọn mọi exception trong catch là cách nhanh nhất để giấu luôn bug thật của bạn dưới vỏ bọc “validation error”.

4.1. Validate API Response

Ranh giới network là chỗ safeParse() toả sáng: res.json() trả về any, và bạn muốn xử lý data hỏng chứ không muốn app nổ.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Nhắc lại schema từ mục 1 (để snippet chạy độc lập)
const userSchema = z.object({
  id: z.uuid(),
  name: z.string().min(1).max(100),
  age: z.number().int().positive(),
  email: z.email(),
  role: z.enum(["admin", "editor", "viewer"]),
});

type User = z.infer<typeof userSchema>;

async function fetchUser(id: string): Promise<User | null> {
  const res = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`);
  const json = await res.json(); // any — chưa có gì đảm bảo

  const result = userSchema.safeParse(json);

  if (!result.success) {
    console.error(
      "API trả về data không hợp lệ:",
      z.prettifyError(result.error),
    );
    return null; // fallback, không crash
  }

  return result.data; // User — đã validate xong
}

const user = await fetchUser("1");
console.log("fetchUser trả về:", user); // null — app không crash

API demo trả về { id: 1, name: "Leanne Graham", email: "Sincere@april.biz", ... }: id là number chứ không phải uuid, còn agerole thì không có. safeParse() bắt hết, và z.prettifyError() in ra thứ đọc được ngay trong console:

1
2
3
4
5
6
7
API trả về data không hợp lệ: Invalid input: expected string, received number
 at id
 Invalid input: expected number, received undefined
 at age
 Invalid option: expected one of "admin"|"editor"|"viewer"
 at role
fetchUser trả về: null

4.2. Map Lỗi Vào Form UI

Muốn map lỗi về từng field để hiện dưới input, dùng z.flattenError():

1
2
3
4
5
6
7
const result = userSchema.safeParse(formData);

if (!result.success) {
  const { fieldErrors } = z.flattenError(result.error);
  // { name: ["..."], age: ["..."] } — key chính là tên field
  setErrors(fieldErrors);
}

Zod 4 có ba utility để format ZodError, chọn theo nhu cầu:

UtilityTrả vềHợp cho
z.flattenError(){ formErrors, fieldErrors } phẳngForm phẳng — map lỗi vào input
z.treeifyError()Object lồng theo shape schemaData lồng nhau, array
z.prettifyError()String cho người đọcLog, debug, CLI

Lưu ý phiên bản

Ở Zod 3 bạn gọi error.flatten()error.format() như method. Zod 4 đã deprecate cả hai — thay bằng function top-level z.flattenError(err)z.treeifyError(err), đúng tinh thần top-level đã thấy ở z.email(), z.uuid().

Chọn cái nào:

Tình huốngDùng
Data hỏng là chuyện bình thường, cần fallback hoặc hiện UI lỗi (form, API response)safeParse()
Data hỏng nghĩa là code đã sai ở đâu đó — cứ để error boundary bắt (config lúc khởi động, env vars)parse()

Trong React, phần lớn thời gian bạn muốn safeParse(): một API response hỏng nên hiện message lỗi tử tế, chứ không nên quật đổ cả cây component.

5. optional(), default() và coerce()

Data thực tế hiếm khi đúng shape lý tưởng: thiếu field, form trả về string thay vì number, query param thì lúc có lúc không. Ba method dưới đây xử lý đúng ba tình huống đó — và coerce là cái giấu nhiều bẫy nhất.

5.1. optional() — Field Không Bắt Buộc

1
2
3
4
5
6
7
8
9
10
11
const profileSchema = z.object({
  name: z.string(),
  bio: z.string().optional(),
  website: z.url().optional(),
});

type Profile = z.infer<typeof profileSchema>;
// → { name: string; bio?: string; website?: string }

profileSchema.parse({ name: "Alice" }); // ✅ { name: "Alice" }
profileSchema.parse({ name: "Alice", bio: "Hello" }); // ✅

Thiếu field thì field đó không xuất hiện trong kết quả, chứ không phải xuất hiện với giá trị undefined. Muốn field luôn xuất hiện, dùng default().

5.2. default() — Giá Trị Mặc Định

1
2
3
4
5
6
7
8
9
const settingsSchema = z.object({
  theme: z.enum(["light", "dark"]).default("light"),
  notifications: z.boolean().default(true),
  language: z.string().default("vi"),
  tags: z.array(z.string()).default([]), // Tránh undefined array
});

settingsSchema.parse({});
// → { theme: "light", notifications: true, language: "vi", tags: [] }

Đây chính là chỗ z.input khác z.infer (mục 2): input cho phép thiếu theme, còn output thì theme luôn là string. Component nhận data đã parse không bao giờ phải check undefined — đó mới là giá trị thật của default().

5.3. coerce() — Tự Động Convert Type

<input> trong HTML luôn trả về string, kể cả type="number". coerce convert giá trị trước khi validate, thay vì bắt bạn tự Number(...) ở mọi chỗ:

1
2
3
4
5
6
7
const formSchema = z.object({
  age: z.coerce.number(),
  count: z.coerce.number().int().positive(),
});

formSchema.parse({ age: "30", count: "5" });
// → { age: 30, count: 5 }

Thứ tự chạy: coerce trước, refinement sau. Với z.coerce.number().int().positive(), Zod convert string → number rồi mới check .int().positive(). Nên "3.5" convert thành 3.5 ngon lành, xong fail ở .int().

Vấn đề là coerce dùng thẳng constructor của JavaScript (Number(), Boolean()) — nghĩa là nó thừa hưởng luôn mọi hành vi quái đản của mấy function đó.

Lưu ý: string rỗng biến thành 0

Bẫy nguy hiểm nhất, vì nó không throw:

1
2
3
4
5
z.coerce.number().parse(""); // → 0   (!!)
z.coerce.number().parse("   "); // → 0   (!!)
z.coerce.number().parse(null); // → 0   (!!)
z.coerce.number().parse("abc"); // → ❌ throw
z.coerce.number().parse(undefined); // → ❌ throw

User để trống ô input → ""Number("")0 → schema pass, và bạn ghi số 0 vào database như thể họ đã cố ý điền. Trớ trêu là field thiếu hẳn thì lại throw đàng hoàng, còn field bỏ trống thì lọt êm.

Cách xử lý: đừng coerce. Nhận string, chặn rỗng, rồi mới transform sang number và validate tiếp bằng pipe:

1
2
3
4
5
6
7
8
9
10
11
12
const ageSchema = z
  .string()
  .min(1, "Vui lòng nhập tuổi") // chặn "" ngay từ đầu
  .transform(Number)
  .pipe(
    z.number().int("Tuổi phải là số nguyên").positive("Tuổi phải lớn hơn 0"),
  );

ageSchema.parse("30"); // ✅ 30
ageSchema.parse(""); // ❌ "Vui lòng nhập tuổi" — thay vì lặng lẽ ra 0
ageSchema.parse("3.5"); // ❌ "Tuổi phải là số nguyên"
ageSchema.parse("-1"); // ❌ "Tuổi phải lớn hơn 0"

Mỗi bước sai cho ra một message riêng, đổ thẳng vào form UI được luôn. Lưu ý là .pipe() không ghép được với z.coerce.number() — schema coerce nhận input unknown, TypeScript sẽ báo lỗi ngay. Dùng .transform(Number) như trên.

Lưu ý: z.coerce.boolean() gần như luôn trả về true

z.coerce.boolean() chạy Boolean(input), mà mọi string khác rỗng đều truthy:

1
2
3
z.coerce.boolean().parse("false"); // → true  (!!)
z.coerce.boolean().parse("0"); // → true  (!!)
z.coerce.boolean().parse(""); // → false

Nói cách khác nó gần như vô dụng với string. Zod 4 có z.stringbool() sinh ra đúng cho việc này (env var, query string, checkbox):

1
2
3
4
z.stringbool().parse("false"); // → false
z.stringbool().parse("0"); // → false
z.stringbool().parse("yes"); // → true
z.stringbool().parse("maybe"); // → ❌ throw

Mặc định z.stringbool() nhận "true" | "1" | "yes" | "on" | "y" | "enabled"true, và "false" | "0" | "no" | "off" | "n" | "disabled"false. Gặp string ngoài danh sách thì throw — đúng thứ bạn muốn.

coerce + default cho query string

Hai method này ghép rất hợp: default() chặn undefined trước, nên coerce không bao giờ phải nhìn thấy giá trị thiếu.

1
2
3
4
5
6
7
8
const querySchema = z.object({
  page: z.coerce.number().default(1),
  limit: z.coerce.number().default(20),
});

const params = new URLSearchParams("page=2&limit=10");
querySchema.parse(Object.fromEntries(params)); // → { page: 2, limit: 10 }
querySchema.parse({}); // → { page: 1, limit: 20 }

6. Validate React Hook Form

Đây là chỗ Zod trả lại nhiều giá trị nhất. Qua @hookform/resolvers, một schema lo trọn ba việc: validate input, sinh message lỗi, và cho ra type của form.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

const loginSchema = z.object({
  email: z.email("Email không hợp lệ"),
  password: z.string().min(8, "Mật khẩu ít nhất 8 ký tự"),
});

type LoginData = z.infer<typeof loginSchema>;

function LoginForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<LoginData>({
    resolver: zodResolver(loginSchema),
  });

  const onSubmit = (data: LoginData) => {
    // data đã validate xong — type-safe
    console.log(data.email, data.password);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("email")} />
      {errors.email && <span>{errors.email.message}</span>}

      <input type="password" {...register("password")} />
      {errors.password && <span>{errors.password.message}</span>}

      <button type="submit">Đăng nhập</button>
    </form>
  );
}

// Chính schema đó sinh ra message lỗi mà form hiển thị — validate thử ngoài React:
const result = loginSchema.safeParse({ email: "sai-dinh-dang", password: "123" });
if (!result.success) {
  console.error(z.prettifyError(result.error));
}

Message bạn truyền vào z.email("Email không hợp lệ") chính là message hiện lên ở errors.email.message. Không cần map lỗi thủ công, cũng không cần khai báo type form riêng — onSubmit chỉ chạy khi schema pass, nên data luôn hợp lệ.

Lưu ý: form có coerce cần ba generic

Nếu schema dùng coerce hay default() (mục 5), giá trị trướcsau parse khác type nhau. Khai báo cả hai để onSubmit nhận đúng data đã convert:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const schema = z.object({
  name: z.string().min(1),
  age: z.coerce.number().int().positive(), // input là string, output là number
});

const { register, handleSubmit } = useForm<
  z.input<typeof schema>, // giá trị thô trong form
  unknown,
  z.output<typeof schema> // giá trị sau khi parse
>({ resolver: zodResolver(schema) });

const onSubmit = (data: z.output<typeof schema>) => {
  data.age.toFixed(2); // ✅ number thật, không phải string
};

Zod giải quyết gọn bài toán luôn tồn tại trong TypeScript: compile-time types không đủ để đảm bảo runtime safety. Nguyên tắc áp dụng: schema là single source of truth — validate tại mọi ranh giới (API response, form submit, localStorage) và derive type từ schema, không viết type riêng. Làm vậy thì type và validation không bao giờ lệch nhau.

← Previous
CVA: Quản Lý Variants Type-Safe Cho React Components
Table of Contents
  1. 1. Zod là gì và dùng khi nào
  2. 2. z.infer — Derive Type Từ Schema
  3. 3. satisfies z.ZodType — Đảm Bảo Schema Khớp Type
  4. 4. parse() vs safeParse()
    1. 4.1. Validate API Response
    2. 4.2. Map Lỗi Vào Form UI
  5. 5. optional(), default() và coerce()
    1. 5.1. optional() — Field Không Bắt Buộc
    2. 5.2. default() — Giá Trị Mặc Định
    3. 5.3. coerce() — Tự Động Convert Type
  6. 6. Validate React Hook Form