Cách đây không lâu, một API đối tác đổi user_id từ number sang string. Build vẫn xanh, type check vẫn sạch, app vẫn chạy. Đúng hai tuần sau mới có người gọi: hoá đơn in ra NaN. Chỗ nổ thì nằm sâu trong module tính tiền, còn chỗ gây ra lỗi nằm tận lớp gọi API bên ngoài — cách nhau mấy tầng code và mấy ngày log.

Lỗi đó mình tự gây. Và nó dạy mình một câu ngắn: parse ở biên, tin ở trong.

Type chỉ là lời hứa lúc compile

TypeScript xoá sạch type khi build — không còn gì kiểm tra lúc chạy. interface User không phải một bản kiểm tra, nó là lời hứa mình tự viết ra và tự tin.

type User = { id: number; name: string };

const res = await fetch("https://api.partner.dev/users/42");
const user = (await res.json()) as User;   // TS tin, runtime không kiểm gì

console.log(user.name.toUpperCase());      // "undefined is not a function", ở đâu đó rất xa

as User chỉ bảo compiler "im đi, tao biết tao đang làm gì". Còn JSON.parse trả any, và any thì lan ra khắp hàm gọi nó.

Bốn chỗ dữ liệu lạ chui vào app

Trong ruột app, type khá đáng tin vì mình tạo ra dữ liệu đó. Ở biên thì không:

  • process.env lúc khởi động
  • body/query từ client
  • response của service bên thứ ba
  • message từ queue, webhook, hoặc job cũ nằm trong DB

Đây là danh sách đóng. Ngoài bốn chỗ này ra, mọi as đều là món nợ.

Parse khác validate ở chỗ nào

Validate là kiểm rồi vứt kết quả: hàm trả boolean, hoặc throw, còn type vẫn rộng như cũ — nên chỗ dùng lại phải kiểm lần nữa. Parse là biến dữ liệu thành type hẹp rồi truyền đi, và từ đó về sau không ai phải kiểm lại.

import { z } from "zod";

const UserSchema = z.object({
  id: z.coerce.number().int().positive(),
  name: z.string().min(1),
  email: z.email(),
});

type User = z.infer<typeof UserSchema>;   // schema là nguồn sự thật duy nhất

const user = UserSchema.parse(await res.json());
console.log(user.name.toUpperCase());     // an toàn thật, không phải diễn

Một chỗ khai báo, hai thứ nhận được: validator lúc chạy và type lúc compile. Sửa schema là sửa cả hai, không có cơ hội để chúng lệch nhau.

parse hay safeParse — tuỳ chỗ đứng

Ở biên nói chuyện với người dùng, mình dùng safeParse và trả về lỗi theo từng field, vì thông báo "invalid input" chẳng giúp ai:

const parsed = LoginSchema.safeParse(body);
if (!parsed.success) {
  return Response.json({
    errors: parsed.error.issues.map((i) => ({ field: i.path.join("."), message: i.message })),
  }, { status: 400 });
}

Ở biên nội bộ — config, service nội bộ, message từ queue — thì dùng parse và để nó throw ngay. Chạy tiếp với config sai còn tệ hơn crash lúc boot.

Env: parse một lần lúc khởi động

Đây là chỗ mình hối hận lâu nhất — trước kia mình rải process.env.SOMETHING khắp code, mỗi chỗ tự đoán là có hay không:

const port = Number(process.env.PORT);   // undefined -> NaN, app listen NaN

App vẫn "chạy", chỉ là không nhận request nào. Giờ mình gom hết vào một file:

const EnvSchema = z.object({
  NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
  DATABASE_URL: z.url(),
  PORT: z.coerce.number().int().min(1).max(65535).default(3000),
  STRIPE_SECRET: z.string().startsWith("sk_"),
});

export const env = EnvSchema.parse(process.env);

Thiếu biến hay sai định dạng thì app chết lúc boot kèm tên biến thiếu — chết ở máy mình tốt hơn chết trên production lúc 2 giờ sáng. Node có sẵn --env-file=.env nên không cần dotenv.

Vài thứ mình phải bỏ

Parse không miễn phí, và mình từng lạm dụng:

  • Parse lại dữ liệu do chính mình tạo. Row đọc qua ORM đã có type, hàm nội bộ nhận tham số typed — parse thêm là trả tiền cho sự yên tâm giả.
  • Parse trong vòng lặp nóng. Vài nghìn item mỗi request thì phải đo trước. Zod 4 nhanh hơn hẳn v3 và zod/mini nhẹ hơn cho bundle; Valibot, ArkType cũng đáng thử nếu chạy ở edge. Cả ba theo chuẩn Standard Schema, nên đổi lib không phải viết lại adapter.
  • Tin coerce quá nhanh. z.coerce.boolean() coi chuỗi "false" là true, vì mọi chuỗi khác rỗng đều truthy. Bug này rất hay gặp với biến môi trường dạng DEBUG=false.
  • Nuốt lỗi parse. catch rỗng rồi trả object mặc định là cách nhanh nhất để dữ liệu rác đi sâu vào DB.

Chốt

Type ở biên không phải type, nó là giả định. Mình chỉ thấy an tâm khi mọi thứ qua cổng đều đã bị parse thành type hẹp, và phía sau cổng thì không ai còn phải hỏi "dữ liệu này có đúng không".

Lần cuối bạn bị undefined ở một chỗ chẳng liên quan gì — có phải gốc rễ nằm ở một cái as nào đó cách đó vài tầng?

📋 Phụ lục thuật ngữ

Thuật ngữ Giải thích ngắn
Parse, don't validate Kiểm dữ liệu rồi biến nó thành type hẹp để truyền đi, thay vì chỉ kiểm rồi trả về boolean
Type erasure TypeScript bị xoá hoàn toàn khi build, runtime không còn gì để kiểm
any lan truyền Giá trị any làm mất type safety của mọi hàm nhận nó
z.infer Lấy type TypeScript từ schema, để schema là nguồn sự thật duy nhất
safeParse Trả về object có success thay vì throw — dùng khi cần báo lỗi cho người dùng
coerce Ép kiểu trước khi kiểm, hay dùng cho env vì biến môi trường luôn là chuỗi
Fail fast Chết ngay lúc khởi động khi cấu hình sai, thay vì chạy sai âm thầm
Standard Schema Chuẩn chung để thư viện nhận schema (Zod, Valibot, ArkType) mà không cần adapter riêng

Xem các bài viết khác