Tại sao Xác thực Runtime là Mảnh ghép còn Thiếu trong Dự án TypeScript
TypeScript mang lại sự an toàn về kiểu dữ liệu tại thời điểm biên dịch — nhưng ngay khi dữ liệu vượt qua ranh giới mạng, mọi thứ đều không còn đảm bảo nữa. Một response từ API, một lần gửi form, một query parameter: không có thứ nào trong số này được đánh kiểu lúc chạy thực. TypeScript đơn giản là tin tưởng vào bất kỳ cấu trúc nào bạn đã khai báo. Khi thực tế không khớp với khai báo đó, bạn sẽ gặp phải những lỗi âm thầm chỉ xuất hiện khi lên production.
Tôi đã phải chịu đựng điều này nhiều lần hơn tôi muốn thừa nhận. Trong kinh nghiệm thực tế của mình, đây là một trong những kỹ năng cốt lõi cần nắm vững — xác thực dữ liệu tại biên giới của ứng dụng, nơi thế giới không đáng tin cậy gặp gỡ code TypeScript đã được đánh kiểu của bạn. Đó chính xác là khoảng trống mà Zod lấp đầy.
Zod là thư viện khai báo schema và xác thực ưu tiên TypeScript. Bạn định nghĩa một schema một lần, và Zod cung cấp cho bạn hai thứ: xác thực runtime (từ chối dữ liệu sai ngay lập tức) và suy luận kiểu TypeScript (không cần khai báo kiểu trùng lặp). Cả hai luôn đồng bộ tự động, giúp loại bỏ hoàn toàn một loại lỗi.
Cài đặt
Zod hoạt động trong bất kỳ dự án TypeScript nào — backend Node.js, frontend React, hay các package dùng chung trong monorepo.
npm install zod
# hoặc
pnpm add zod
# hoặc
yarn add zod
Để tích hợp React Hook Form, bạn cũng cần Zod resolver:
npm install react-hook-form @hookform/resolvers zod
Đó là toàn bộ phụ thuộc cần thiết. Zod không có bất kỳ dependency ngoài nào và hoạt động trong mọi môi trường hiện đại bao gồm cả Deno và Bun.
Kiểm tra phiên bản TypeScript của bạn là 4.5 trở lên — Zod dựa vào template literal types và các tính năng suy luận mới hơn:
npx tsc --version
Cấu hình: Xây dựng Schema đầu tiên
Định nghĩa Schema cơ bản và Suy luận Kiểu dữ liệu
Khái niệm cốt lõi rất đơn giản: định nghĩa một schema bằng các primitive của Zod, sau đó trích xuất kiểu TypeScript từ nó bằng z.infer.
import { z } from 'zod';
// Định nghĩa schema một lần
const UserSchema = z.object({
id: z.number().int().positive(),
email: z.string().email(),
username: z.string().min(3).max(20),
role: z.enum(['admin', 'editor', 'viewer']),
createdAt: z.string().datetime().optional(),
});
// Trích xuất kiểu TypeScript — không trùng lặp
type User = z.infer<typeof UserSchema>;
// Lúc này User tương đương với:
// {
// id: number;
// email: string;
// username: string;
// role: 'admin' | 'editor' | 'viewer';
// createdAt?: string | undefined;
// }
Đây là điểm mấu chốt: bạn viết schema, TypeScript tự viết kiểu dữ liệu cho bạn. Khi bạn cập nhật schema, kiểu dữ liệu tự động cập nhật theo. Không còn tình trạng quên cập nhật interface khi thêm field vào logic xác thực nữa.
Phân tích Runtime và Xử lý Lỗi
Zod cung cấp cho bạn hai phương thức để xác thực dữ liệu: parse() ném lỗi khi thất bại, safeParse() trả về một object kết quả.
const rawData = {
id: 1,
email: '[email protected]',
username: 'john_doe',
role: 'admin',
};
// Cách 1: parse — ném ZodError nếu không hợp lệ
try {
const user = UserSchema.parse(rawData);
console.log(user.email); // đã được đánh kiểu đầy đủ
} catch (err) {
console.error(err); // ZodError với thông báo chi tiết
}
// Cách 2: safeParse — trả về { success, data } hoặc { success, error }
const result = UserSchema.safeParse(rawData);
if (result.success) {
console.log(result.data.role); // kiểu là 'admin' | 'editor' | 'viewer'
} else {
console.error(result.error.flatten());
// { fieldErrors: { email: ['Email không hợp lệ'] }, formErrors: [] }
}
safeParse() thường được ưu tiên trong code ứng dụng vì nó không làm gián đoạn luồng thực thi — bạn xử lý trường hợp lỗi một cách tường minh mà không cần cú pháp try/catch rườm rà.
Các Pattern Thực tế: Biến đổi, Giá trị mặc định và Schema lồng nhau
Các schema kết hợp một cách tự nhiên, và Zod hỗ trợ biến đổi (transform) để ép kiểu hoặc định hình lại dữ liệu trong quá trình phân tích:
// Schema lồng nhau
const AddressSchema = z.object({
street: z.string(),
city: z.string(),
country: z.string().length(2), // mã quốc gia ISO
});
const ProfileSchema = z.object({
user: UserSchema,
address: AddressSchema.optional(),
tags: z.array(z.string()).default([]),
});
// Biến đổi: chuyển chuỗi ngày thành đối tượng Date
const EventSchema = z.object({
name: z.string(),
startDate: z.string().transform((val) => new Date(val)),
attendeeCount: z.coerce.number(), // ép kiểu chuỗi '42' thành số 42
});
type Event = z.infer<typeof EventSchema>;
// startDate là Date (sau biến đổi), không phải string
// Tinh chỉnh: logic xác thực tùy chỉnh
const PasswordSchema = z
.object({
password: z.string().min(8),
confirm: z.string(),
})
.refine((data) => data.password === data.confirm, {
message: 'Mật khẩu không khớp',
path: ['confirm'],
});
Tích hợp Zod với React Hook Form
React Hook Form quản lý trạng thái form một cách hiệu quả, nhưng cơ chế xác thực tích hợp sẵn của nó lại tách biệt với các kiểu TypeScript của bạn. Package @hookform/resolvers đóng vai trò cầu nối — bạn truyền một Zod schema làm resolver, và React Hook Form sử dụng nó cho cả xác thực lẫn suy luận kiểu TypeScript.
// components/RegistrationForm.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const RegistrationSchema = z
.object({
email: z.string().email('Vui lòng nhập email hợp lệ'),
username: z
.string()
.min(3, 'Tên đăng nhập phải có ít nhất 3 ký tự')
.max(20, 'Tên đăng nhập không được vượt quá 20 ký tự')
.regex(/^[a-z0-9_]+$/, 'Chỉ cho phép chữ thường, số và dấu gạch dưới'),
password: z.string().min(8, 'Mật khẩu phải có ít nhất 8 ký tự'),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Mật khẩu không khớp',
path: ['confirmPassword'],
});
type RegistrationFormData = z.infer<typeof RegistrationSchema>;
export function RegistrationForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<RegistrationFormData>({
resolver: zodResolver(RegistrationSchema),
});
const onSubmit = async (data: RegistrationFormData) => {
// data đã được đánh kiểu đầy đủ và đã được xác thực
await fetch('/api/register', {
method: 'POST',
body: JSON.stringify(data),
});
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<input {...register('email')} placeholder="Email" />
{errors.email && <p>{errors.email.message}</p>}
</div>
<div>
<input {...register('username')} placeholder="Tên đăng nhập" />
{errors.username && <p>{errors.username.message}</p>}
</div>
<div>
<input type="password" {...register('password')} placeholder="Mật khẩu" />
{errors.password && <p>{errors.password.message}</p>}
</div>
<div>
<input
type="password"
{...register('confirmPassword')}
placeholder="Xác nhận mật khẩu"
/>
{errors.confirmPassword && <p>{errors.confirmPassword.message}</p>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Đang tạo tài khoản...' : 'Đăng ký'}
</button>
</form>
);
}
Điều tuyệt vời của cách thiết lập này: cùng một RegistrationSchema có thể được import vào API route để xác thực body request đến. Một schema, xác thực cả ở phía client lẫn server.
Chia sẻ Schema giữa Frontend và Backend
Đây là lúc lợi ích full-stack trở nên rõ ràng. Tạo một file schema dùng chung:
// shared/schemas.ts
import { z } from 'zod';
export const RegistrationSchema = z
.object({
email: z.string().email(),
username: z.string().min(3).max(20),
password: z.string().min(8),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Mật khẩu không khớp',
path: ['confirmPassword'],
});
export type RegistrationFormData = z.infer<typeof RegistrationSchema>;
// pages/api/register.ts (API route của Next.js)
import { RegistrationSchema } from '@/shared/schemas';
export async function POST(request: Request) {
const body = await request.json();
const result = RegistrationSchema.safeParse(body);
if (!result.success) {
return Response.json(
{ errors: result.error.flatten().fieldErrors },
{ status: 400 }
);
}
// result.data đã được đánh kiểu đầy đủ tại đây
const { email, username, password } = result.data;
// ... tạo người dùng
}
Kiểm tra và Giám sát: Đảm bảo Schema hoạt động đúng
Kiểm thử Unit Schema trực tiếp
Schema là các hàm thuần túy — chúng rất dễ kiểm thử unit mà không cần mock bất kỳ thứ gì:
// schemas.test.ts
import { RegistrationSchema } from './shared/schemas';
describe('RegistrationSchema', () => {
it('chấp nhận dữ liệu đăng ký hợp lệ', () => {
const result = RegistrationSchema.safeParse({
email: '[email protected]',
username: 'john_doe',
password: 'secure123',
confirmPassword: 'secure123',
});
expect(result.success).toBe(true);
});
it('từ chối mật khẩu không khớp', () => {
const result = RegistrationSchema.safeParse({
email: '[email protected]',
username: 'john_doe',
password: 'secure123',
confirmPassword: 'different456',
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.flatten().fieldErrors.confirmPassword).toBeDefined();
}
});
it('từ chối định dạng email không hợp lệ', () => {
const result = RegistrationSchema.safeParse({
email: 'not-an-email',
username: 'john_doe',
password: 'secure123',
confirmPassword: 'secure123',
});
expect(result.success).toBe(false);
});
});
Giám sát Lỗi Xác thực trong Production
Khi xác thực thất bại trong môi trường production, bạn cần có khả năng quan sát. Bọc việc xác thực API của bạn bằng logging:
const result = RegistrationSchema.safeParse(body);
if (!result.success) {
const errors = result.error.flatten();
// Ghi log vào dịch vụ giám sát của bạn (Sentry, Datadog, v.v.)
console.warn('[Xác thực thất bại]', {
endpoint: '/api/register',
fieldErrors: errors.fieldErrors,
formErrors: errors.formErrors,
});
return Response.json({ errors: errors.fieldErrors }, { status: 400 });
}
Lỗi xác thực liên tục trên một field cụ thể thường báo hiệu có lỗi ở frontend, một client mobile đang gửi sai định dạng dữ liệu, hoặc một người dùng API chưa đọc tài liệu của bạn. Hãy phát hiện những vấn đề này sớm thay vì phải lục tung log sau khi người dùng phàn nàn.
Những Điểm Cần Lưu ý
- Optional vs nullable:
z.string().optional()cho phépundefined;z.string().nullable()cho phépnull. Chúng khác nhau. Dùngz.string().nullish()để chấp nhận cả hai. - Key không xác định: Mặc định, Zod loại bỏ các key không xác định trong quá trình phân tích. Gọi
.passthrough()để giữ lại chúng hoặc.strict()để ném lỗi khi gặp chúng. - Tinh chỉnh bất đồng bộ: Dùng
.refineAsync()vàparseAsync()khi xác thực của bạn cần truy vấn database (ví dụ: kiểm tra xem email đã được đăng ký chưa). - Thông báo lỗi: Thông báo tùy chỉnh đặt trong lệnh gọi validator:
z.string().min(3, 'Quá ngắn')— không phải trong một object cấu hình riêng biệt.

