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:
- Form — validate input trước khi submit
- API response — dữ liệu từ service bên ngoài, bạn không kiểm soát được
- 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ống | Dùng |
|---|---|
| Schema là source of truth, type derive ra | z.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);
}result là discriminated union trên field success, nên sau khi check if (result.success) thì TypeScript tự narrow: nhánh true có result.data, nhánh false có result.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 crashAPI demo trả về { id: 1, name: "Leanne Graham", email: "Sincere@april.biz", ... }: id là number chứ không phải uuid, còn age và role 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ề: null4.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:
| Utility | Trả về | Hợp cho |
|---|---|---|
z.flattenError() | { formErrors, fieldErrors } phẳng | Form phẳng — map lỗi vào input |
z.treeifyError() | Object lồng theo shape schema | Data lồng nhau, array |
z.prettifyError() | String cho người đọc | Log, debug, CLI |
Lưu ý phiên bản
Ở Zod 3 bạn gọi error.flatten() và error.format() như method. Zod 4 đã deprecate cả hai — thay bằng function top-level z.flattenError(err) và z.treeifyError(err), đúng tinh thần top-level đã thấy ở z.email(), z.uuid().
Chọn cái nào:
| Tình huống | Dù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() và .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); // → ❌ throwUser để trống ô input → "" → Number("") là 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(""); // → falseNó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"); // → ❌ throwMặc định z.stringbool() nhận "true" | "1" | "yes" | "on" | "y" | "enabled" là true, và "false" | "0" | "no" | "off" | "n" | "disabled" là 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ước và sau 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.