Zod: Validate Dữ Liệu Type-Safe Trong React
TypeScript chỉ kiểm tra type lúc compile time — khi code đang chạy, mọi dữ liệu từ API, form, hay localStorage đều là unknown cho đến khi bạn kiểm tra. Nếu API trả về { age: "30" } thay vì { age: 30 }, TypeScript không biết vì nó tin vào type bạn khai báo.
Zod giải quyết bài toán này: định nghĩa schema một lần, tự động derive TypeScript type từ đó, và validate dữ liệu lúc runtime. Không cần viết type và validation rule riêng lẻ rồi giữ chúng đồng bộ.
#1. Zod là gì và dùng khi nào
Zod là thư viện validation với TypeScript-first design. Bạn định nghĩa schema bằng Zod, và TypeScript type được derive tự động từ schema đó.
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.string().uuid(),
name: z.string().min(1).max(100),
age: z.number().int().positive(),
email: z.string().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" }Ba use case quan trọng nhất:
- Form validation — validate input trước khi submit
- API requests — validate response từ external service
- Local storage — verify data khi đọc ra (data có thể cũ, sai format)
#2. z.infer — Derive Type Từ Schema
z.infer là utility type để lấy TypeScript type từ Zod schema. Đây là cách để schema là single source of truth.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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(),
}),
});
// Derive — không viết lại type thủ công
type Post = z.infer<typeof postSchema>;
// Khi postSchema thêm field mới, type Post tự cập nhật
// Không bao giờ bị out-of-sync giữa schema và typeDùng trong React component:
1
2
3
4
5
6
7
8
9
10
11
const postSchema = z.object({ id: z.number(), title: z.string(), content: z.string() });
type Post = z.infer<typeof postSchema>;
function PostCard({ post }: { post: Post }) {
return (
<article>
<h2>{post.title}</h2>
<p>{post.content}</p>
</article>
);
}#3. satisfies z.ZodType — Đảm Bảo Schema Khớp Type
Khi bạn đã có TypeScript type sẵn và muốn đảm bảo Zod schema validate đúng type đó, dùng satisfies:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type Post = {
id: number;
title: string;
content: string;
};
// satisfies đảm bảo schema phải match Post
// Nếu schema thiếu field hoặc sai type → compile error
const postSchema = z.object({
id: z.number(),
title: z.string(),
content: z.string(),
}) satisfies z.ZodType<Post>;
// ❌ Nếu schema sai — TypeScript báo lỗi ngay
const postSchema = z.object({
id: z.string(), // ❌ Post.id là number, không phải string
title: z.string(),
content: z.string(),
}) satisfies z.ZodType<Post>;Khi nào dùng satisfies vs z.infer:
z.infer— khi schema là source of truth, type derive từ schemasatisfies z.ZodType<T>— khi type có sẵn và bạn muốn schema phải match type đó
#4. parse() vs safeParse()
Zod có hai cách validate: parse() throw error nếu fail, safeParse() trả về object kết quả.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const numberSchema = z.number().positive();
// parse() — throw ZodError nếu không hợp lệ
try {
const value = numberSchema.parse(-5);
} catch (error) {
console.error(error); // ZodError với message chi tiết
}
// safeParse() — không throw, trả về result object
const result = numberSchema.safeParse(-5);
if (result.success) {
console.log(result.data); // TypeScript biết result.data là number
} else {
console.error(result.error.issues); // Array lỗi chi tiết
}Dùng trong API call:
1
2
3
4
5
6
7
8
9
10
11
12
13
async function fetchUser(id: string): Promise<User | null> {
const res = await fetch(`/api/users/${id}`);
const json = await res.json(); // unknown
const result = userSchema.safeParse(json);
if (!result.success) {
console.error("API trả về data không hợp lệ:", result.error.issues);
return null;
}
return result.data; // User — đã được validate
}Khi nào dùng gì:
parse()— khi bạn muốn để lỗi lan lên và error boundary tự xử lýsafeParse()— khi bạn muốn tự xử lý lỗi validation (hiện UI lỗi, dùng fallback)
#5. optional(), default() và coerce()
Ba method này giúp xử lý các trường hợp data không hoàn toàn như mong đợi.
#5.1. optional() — Field Không Bắt Buộc
1
2
3
4
5
6
7
8
9
10
11
12
const profileSchema = z.object({
name: z.string(),
bio: z.string().optional(), // string | undefined
website: z.string().url().optional(), // string | undefined
});
type Profile = z.infer<typeof profileSchema>;
// → { name: string; bio?: string; website?: string }
// Khi parse — bio và website có thể vắng mặt
profileSchema.parse({ name: "Alice" }); // ✅
profileSchema.parse({ name: "Alice", bio: "Hello" }); // ✅#5.2. default() — Giá Trị Mặc Định
1
2
3
4
5
6
7
8
9
10
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
});
// Nếu field thiếu → dùng giá trị default
settingsSchema.parse({});
// → { theme: "light", notifications: true, language: "vi", tags: [] }#5.3. coerce() — Tự Động Convert Type
coerce convert giá trị sang type mong muốn thay vì throw error. Cực kỳ hữu ích khi làm việc với form data hoặc API trả về string thay vì number.
Lưu ý thứ tự thực thi: coerce chạy trước các refinement. Trong z.coerce.number().int().positive(), Zod convert string → number trước, sau đó mới kiểm tra .int() và .positive(). Điều này có nghĩa nếu coerce thành công nhưng refinement fail (ví dụ: "3.5" → 3.5 nhưng không phải integer), Zod sẽ throw lỗi ở bước refinement.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const formSchema = z.object({
age: z.coerce.number(), // "30" → 30
active: z.coerce.boolean(), // "true" → true, "false" → false
count: z.coerce.number().int().positive(), // "5" → 5 (ok), "3.5" → 3.5 → ❌ int() fail
});
// Form data từ <input type="number"> luôn là string
formSchema.parse({ age: "30", active: "true", count: "5" });
// → { age: 30, active: true, count: 5 }
// Đặc biệt hữu ích với URLSearchParams
const params = new URLSearchParams("page=2&limit=10");
const querySchema = z.object({
page: z.coerce.number().default(1),
limit: z.coerce.number().default(20),
});
querySchema.parse(Object.fromEntries(params));
// → { page: 2, limit: 10 }#6. Type Guard Từ safeParse
Bạn có thể tạo type guard function từ safeParse để thu hẹp type trong code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const postSchema = z.object({ id: z.number(), title: z.string() });
type Post = z.infer<typeof postSchema>;
// Type guard — trả về boolean, TypeScript narrow type nếu true
function isPost(value: unknown): value is Post {
return postSchema.safeParse(value).success;
}
// localStorage.getItem() trả về string|null, không phải unknown
// Cần JSON.parse để chuyển thành object trước khi validate
const rawData: unknown = JSON.parse(localStorage.getItem("cached-post") ?? "null");
if (isPost(rawData)) {
// TypeScript biết rawData là Post tại đây
console.log(rawData.title); // ✅ không cần cast
} else {
console.log("Data không hợp lệ, xóa cache");
localStorage.removeItem("cached-post");
}Generic type guard để tái sử dụng:
1
2
3
4
5
6
7
8
9
10
11
function createValidator<T>(schema: z.ZodType<T>) {
return (value: unknown): value is T => schema.safeParse(value).success;
}
const isUser = createValidator(userSchema);
const isPost = createValidator(postSchema);
// Dùng ngay lập tức
if (isUser(apiResponse)) {
console.log(apiResponse.name); // TypeScript biết là User
}#7. Validate React Hook Form
Zod kết hợp rất tốt với React Hook Form qua @hookform/resolvers:
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
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const loginSchema = z.object({
email: z.string().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 đã được validate và 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>
);
}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.