Nếu bạn từng deploy một service TypeScript với strict: true, không còn chỗ nào dùng any, CI xanh, rồi 2 giờ sáng vẫn bị gọi dậy vì TypeError: Cannot read properties of undefined (reading 'name'), thì bạn không phải người duy nhất. Mình cũng từng gặp. Lúc đầu mình nghĩ TypeScript "không đáng tin", nhưng thật ra TypeScript làm đúng phần việc của nó. Vấn đề là mình đã tin nó ở chỗ nó không hề kiểm soát được: dữ liệu đi vào từ bên ngoài. Bài này nói về một nguyên tắc rất đơn giản đã giúp team mình giảm hẳn lỗi runtime: validate ở biên (boundary), tin tưởng ở bên trong.
Type chỉ tồn tại lúc compile
Điều cần nhớ: TypeScript compile ra JavaScript và toàn bộ type bị xóa sạch (type erasure). Lúc chạy, không còn interface User nào cả. Vì vậy đoạn code dưới đây compile hoàn toàn bình thường:
interface User {
id: number;
name: string;
email: string;
}
async function getUser(id: number): Promise<User> {
const res = await fetch(`https://api.example.com/users/${id}`);
return res.json(); // res.json() trả về Promise<any> -> "tin" là User
}
const user = await getUser(1);
console.log(user.name.toUpperCase()); // 💥 nếu API trả về { id: 1, fullName: "..." }
res.json() trả về any, và any có thể gán vào bất cứ thứ gì. Compiler không cảnh báo gì cả. Bạn vừa viết một câu "hứa" với compiler mà không ai đứng ra kiểm tra.
Những chỗ hay "hứa suông" kiểu này:
-
res.json(),JSON.parse() -
req.body,req.querytrong Express/Fastify process.env.*- Message từ queue (Kafka, RabbitMQ, SQS)
- Dữ liệu đọc từ
localStorage, file config, hoặc cột JSONB trong database - Các
astype assertion rải rác khắp codebase
Mọi chỗ dữ liệu vượt qua ranh giới process đều là untrusted, dù đó là API nội bộ do chính team bạn viết. Team backend đổi tên field mà quên báo là chuyện xảy ra hằng tuần.
Kiến trúc: validate ở biên, tin tưởng bên trong
Ý tưởng là chia hệ thống thành hai vùng. Vùng ngoài là nơi dữ liệu unknown đi vào. Ngay tại cửa, ta parse dữ liệu qua một schema. Nếu hợp lệ, dữ liệu đi vào vùng trong với type chính xác, và từ đó code bên trong không cần kiểm tra lại nữa.
flowchart LR
A[HTTP Request] --> V{Schema parse}
B[External API] --> V
C[Queue message] --> V
D[process.env] --> V
V -- hợp lệ --> E[Typed data]
V -- không hợp lệ --> F[400 / log lỗi / fail fast]
E --> G[Business logic<br/>không cần check lại]
Nguyên tắc này thường được gọi là "Parse, don't validate": thay vì viết hàm isValid() trả về boolean rồi vẫn phải as User, ta viết hàm parse nhận unknown và trả về User đã được chứng minh. Type và runtime check đi cùng nhau, không thể lệch nhau.
Thực hành với Zod 4
Có nhiều thư viện làm việc này: Zod, Valibot, ArkType, TypeBox. Mình dùng Zod (bản 4.x) vì phổ biến nhất, ecosystem lớn (tRPC, React Hook Form, Hono đều hỗ trợ sẵn). Nếu bạn cần bundle size nhỏ cho frontend thì Valibot là lựa chọn tốt hơn.
npm install zod@^4
Viết lại ví dụ trên:
import { z } from "zod";
const UserSchema = z.object({
id: z.number().int().positive(),
name: z.string().min(1),
email: z.email(),
role: z.enum(["admin", "member"]).default("member"),
createdAt: z.coerce.date(), // API trả string ISO -> tự convert sang Date
});
// Type được suy ra từ schema -> chỉ có MỘT nguồn sự thật
type User = z.infer<typeof UserSchema>;
async function getUser(id: number): Promise<User> {
const res = await fetch(`https://api.example.com/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json: unknown = await res.json();
const result = UserSchema.safeParse(json);
if (!result.success) {
// Lỗi xảy ra NGAY TẠI BIÊN, với message rõ ràng
console.error(z.prettifyError(result.error));
throw new Error(`Invalid user payload from API (id=${id})`);
}
return result.data;
}
Điểm khác biệt quan trọng: khi API đổi name thành fullName, bạn nhận được lỗi kiểu ✖ Invalid input: expected string, received undefined → at name ngay tại hàm getUser, thay vì một TypeError mơ hồ ở một component cách đó 5 lớp. Debug từ 2 tiếng còn 2 phút.
Vài mẹo nhỏ:
- Dùng
safeParsekhi muốn tự xử lý lỗi (trả 400, retry), dùngparsekhi muốn throw luôn. -
z.inferthay cho việc viếtinterfaceriêng. Đừng duy trì hai nguồn, sớm muộn sẽ lệch. - Mặc định
z.objectsẽ strip các field lạ. Muốn báo lỗi khi có field thừa thì dùngz.strictObject.
Đừng quên process.env và request body
Hai chỗ crash phổ biến nhất mà mình thấy trong các dự án Node.js là biến môi trường và request body. Với env, hãy validate một lần lúc khởi động và fail fast:
// src/env.ts
import { z } from "zod";
const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]),
PORT: z.coerce.number().int().default(3000),
DATABASE_URL: z.url(),
REDIS_TTL_SECONDS: z.coerce.number().positive().default(300),
});
const parsed = EnvSchema.safeParse(process.env);
if (!parsed.success) {
console.error("❌ Invalid environment:\n" + z.prettifyError(parsed.error));
process.exit(1); // Container crash ngay khi start, không phải 3 ngày sau
}
export const env = parsed.data; // env.PORT là number, không phải string | undefined
Từ giờ trong code chỉ import env từ file này, cấm dùng process.env trực tiếp. Có thể enforce bằng ESLint rule n/no-process-env.
Với request body, luồng xử lý trong một API handler sẽ trông thế này:
sequenceDiagram
participant C as Client
participant H as Route handler
participant S as Zod schema
participant D as Service / DB
C->>H: POST /orders (JSON)
H->>S: safeParse(req.body)
alt không hợp lệ
S-->>H: error.issues
H-->>C: 400 + chi tiết field lỗi
else hợp lệ
S-->>H: data (typed)
H->>D: createOrder(data)
D-->>H: order
H-->>C: 201 Created
end
Nếu dùng Hono thì có sẵn @hono/zod-validator, Fastify có fastify-type-provider-zod, tRPC thì nhận schema Zod trực tiếp ở .input(). Không cần tự viết middleware.
Chi phí và những chỗ không nên lạm dụng
Validation không miễn phí. Vài điều mình rút ra sau khi áp dụng thực tế:
- Performance: Zod 4 nhanh hơn bản 3 đáng kể, nhưng parse một mảng 50.000 object vẫn tốn vài chục ms. Với hot path xử lý dữ liệu lớn, cân nhắc chỉ validate phần cần thiết hoặc dùng thư viện compile schema như TypeBox + Ajv.
- Chỉ validate ở biên: đừng parse lại cùng một object ở mỗi function. Đã qua cửa rồi thì tin type.
- Response từ chính database của bạn (qua ORM có type như Prisma, Drizzle) thường không cần validate lại, trừ cột JSON/JSONB.
-
Log lỗi validation có cấu trúc: gửi
error.issuesvào log để biết API nào, field nào hay vỡ. Đây là tín hiệu rất tốt để phát hiện contract drift giữa các team.
Kết luận
TypeScript bảo vệ bạn khỏi lỗi bên trong code của bạn, không bảo vệ bạn khỏi thế giới bên ngoài. Những việc có thể làm ngay trong tuần này:
-
Grep codebase: tìm
res.json(),JSON.parse,asvàprocess.env. Đó là danh sách các biên chưa được bảo vệ. -
Bắt đầu với env: tạo
src/env.tsnhư trên. Mất 15 phút, lợi ích thấy ngay. -
Validate mọi request body bằng schema, trả 400 với
error.issuesthay vì để handler crash thành 500. -
Bọc các API call bên ngoài bằng
safeParse, log lỗi có cấu trúc. -
Dùng
z.inferlàm nguồn type duy nhất, xóa cácinterfacetrùng lặp. -
Bật thêm
noUncheckedIndexedAccesstrongtsconfig.jsonđể bắt nốt các lỗiarr[i]có thể làundefined.
Cứ coi mỗi biên của hệ thống như cửa an ninh sân bay: kiểm tra kỹ một lần ở cửa, còn vào bên trong rồi thì không ai phải xuất trình hộ chiếu lần nữa. Code sẽ gọn hơn, mà bạn cũng ngủ ngon hơn.
Top comments (0)