DEV Community

EME GUG
EME GUG

Posted on

Why Your TypeScript Code Still Crashes in Production: Validating Data at Runtime Boundaries with Zod

Mình từng review một incident lúc 2 giờ sáng: service Node.js crash hàng loạt với lỗi TypeError: Cannot read properties of undefined (reading 'toFixed'). Codebase 100% TypeScript, bật strict: true, CI xanh, không có any nào. Vậy mà vẫn sập. Nguyên nhân là bên đối tác đổi field price từ number sang string "12.50", và có record thì trả về null. TypeScript không hề biết chuyện này, vì type chỉ tồn tại lúc compile, còn data thật đến lúc runtime.

Bài này chia sẻ cách mình xử lý vấn đề đó trong các dự án thực tế: xác định trust boundary và validate data ngay tại đó bằng Zod (v4), cùng vài option tsconfig ít người bật nhưng rất đáng bật.

TypeScript nói dối bạn ở đâu?

TypeScript bị type erasure: sau khi compile ra JavaScript thì toàn bộ type biến mất. Mọi chỗ bạn viết as SomeType hay khai báo kiểu trả về cho response.json() thì thực chất bạn đang hứa với compiler, chứ không ai kiểm tra lời hứa đó cả.

Đây là đoạn code mình gặp nhiều nhất:

interface Product {
  id: string;
  name: string;
  price: number;
}

async function getProduct(id: string): Promise<Product> {
  const res = await fetch(`https://api.partner.com/products/${id}`);
  return res.json() as Promise<Product>; // lời hứa suông
}

const product = await getProduct("abc");
console.log(product.price.toFixed(2)); // crash nếu price là null hoặc string
Enter fullscreen mode Exit fullscreen mode

Compiler thấy ổn, IDE autocomplete đẹp, nhưng res.json() trả về Promise<any>, và any thì cast sang gì cũng được. Những chỗ hay dính kiểu bug này:

  • Response từ API bên ngoài (fetch, axios)
  • JSON.parse() từ file, cache Redis, message queue
  • process.env
  • Request body, query string, form data
  • localStorage, URL params ở frontend
  • Kết quả từ LLM API (structured output cũng có lúc trả sai schema)

Điểm chung: đều là data đi từ bên ngoài vào hệ thống.

Trust boundary: validate một lần ở cổng vào

Cách nghĩ mình áp dụng: chia hệ thống làm hai vùng. Bên ngoài là vùng unknown, không tin gì cả. Bên trong là vùng đã được kiểm tra, type đúng thật. Validate đúng một lần tại ranh giới, sau đó bên trong code thoải mái dùng type mà không cần check if (x && x.y) khắp nơi.

flowchart LR
    A[External API] --> V{Zod schema<br/>safeParse}
    B[Request body] --> V
    C[process.env] --> V
    D[Redis / Queue] --> V
    V -->|success| E[Typed data<br/>business logic]
    V -->|fail| F[Log + trả lỗi rõ ràng]
    E --> G[Database / Response]

Lợi ích lớn nhất không phải là chặn crash, mà là lỗi xảy ra đúng chỗ. Thay vì crash ở tầng tính toán hóa đơn với message vô nghĩa, bạn nhận lỗi ngay tại chỗ gọi API: price: expected number, received string. Debug nhanh hơn rất nhiều.

Thực hành với Zod 4

Cài đặt:

npm install zod@^4
# yêu cầu TypeScript >= 5.5, bật strict mode
Enter fullscreen mode Exit fullscreen mode

Viết lại ví dụ trên. Điểm hay của Zod là schema vừa là validator lúc runtime, vừa sinh ra type lúc compile qua z.infer, nên không bao giờ bị lệch giữa interface và logic kiểm tra:

import { z } from "zod";

const ProductSchema = z.object({
  id: z.string(),
  name: z.string().min(1),
  // chấp nhận cả "12.50" lẫn 12.5, ép về number
  price: z.coerce.number().nonnegative(),
  tags: z.array(z.string()).default([]),
});

type Product = z.infer<typeof ProductSchema>;

async function getProduct(id: string): Promise<Product> {
  const res = await fetch(`https://api.partner.com/products/${id}`);
  if (!res.ok) throw new Error(`Partner API ${res.status}`);

  const raw: unknown = await res.json();
  const result = ProductSchema.safeParse(raw);

  if (!result.success) {
    console.error("Invalid product payload", {
      id,
      issues: z.prettifyError(result.error),
    });
    throw new Error(`Partner API trả data sai schema cho product ${id}`);
  }
  return result.data; // từ đây trở đi type là thật
}
Enter fullscreen mode Exit fullscreen mode

Vài kinh nghiệm thực tế:

  • Dùng safeParse thay vì parse ở tầng I/O để tự quyết định cách xử lý lỗi (log, metric, fallback), không để exception bay lung tung.
  • Gán unknown cho data thô (const raw: unknown) thay vì để any. Compiler sẽ ép bạn validate trước khi dùng.
  • Cẩn thận với z.coerce: z.coerce.number() biến null thành 0 và "" thành 0. Với tiền bạc, mình thường dùng z.union([z.number(), z.string().regex(/^\d+(\.\d+)?$/).transform(Number)]) để chặt hơn.
  • Mặc định Zod object sẽ bỏ field lạ (strip). Nếu cần phát hiện API đổi schema sớm, dùng z.strictObject() trong môi trường staging.

Validate env ngay khi khởi động

Đây là chỗ đáng làm nhất mà ít người làm. Thiếu một biến env thì app nên chết ngay lúc boot, không phải chết lúc 3 giờ sáng khi lần đầu chạy vào code path đó:

// src/env.ts
import { z } from "zod";

const EnvSchema = z.object({
  NODE_ENV: z.enum(["development", "staging", "production"]),
  DATABASE_URL: z.url(),
  PORT: z.coerce.number().int().default(3000),
  REDIS_TTL_SECONDS: z.coerce.number().int().positive().default(300),
  ENABLE_NEW_CHECKOUT: z.stringbool().default(false),
});

const parsed = EnvSchema.safeParse(process.env);
if (!parsed.success) {
  console.error("❌ Env không hợp lệ:\n" + z.prettifyError(parsed.error));
  process.exit(1);
}

export const env = parsed.data;
Enter fullscreen mode Exit fullscreen mode

Sau đó toàn bộ codebase import env thay vì đọc process.env trực tiếp. Bạn có thể thêm rule ESLint no-restricted-properties để cấm process.env ngoài file này.

Đừng quên mấy option tsconfig bị bỏ quên

strict: true chưa phải là strict nhất. Có hai option mình luôn bật cho project mới:

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true
  }
}
Enter fullscreen mode Exit fullscreen mode

noUncheckedIndexedAccess làm arr[0] có type T | undefined thay vì T. Rất nhiều crash production đến từ items[0].id khi mảng rỗng. Bật lên lần đầu sẽ ra vài trăm lỗi, nhưng phần lớn là bug thật đang ngủ.

Luồng xử lý một request khi kết hợp đủ các lớp bảo vệ:

sequenceDiagram
    participant C as Client
    participant H as Handler
    participant Z as Zod Schema
    participant S as Service
    C->>H: POST /orders (body: unknown)
    H->>Z: OrderSchema.safeParse(body)
    alt invalid
        Z-->>H: issues[]
        H-->>C: 400 + chi tiết field lỗi
    else valid
        Z-->>H: Order (typed)
        H->>S: createOrder(order)
        S-->>C: 201 Created
    end

Về performance: Zod 4 nhanh hơn đáng kể so với v3, parse một object vài chục field chỉ tốn vài micro giây. Với API thông thường, chi phí này không đáng kể so với một query database. Nếu thực sự cần tối ưu hot path (hàng trăm nghìn message/giây), có thể cân nhắc Valibot hoặc TypeBox kèm compiled validator, nhưng hãy đo trước rồi hãy đổi.

Kết luận

TypeScript bảo vệ bạn khỏi bug do chính bạn viết ra, nhưng không bảo vệ bạn khỏi thế giới bên ngoài. Muốn code TypeScript không crash ở production, hãy làm ngay mấy việc sau:

  1. Liệt kê các trust boundary trong service: API ngoài, request body, env, cache, queue, output từ LLM.
  2. Grep các chỗ as và res.json(): grep -rnE "as [A-Z]|\.json\(\)" src/ và thay bằng schema + safeParse.
  3. Tạo file env.ts validate env lúc boot, cho app fail fast.
  4. Bật noUncheckedIndexedAccess và sửa dần các lỗi, ưu tiên module quan trọng.
  5. Log issues khi validate fail kèm metric, để biết ngay khi đối tác âm thầm đổi API.

Nguyên tắc gói gọn trong một câu: data từ ngoài vào là unknown cho đến khi được chứng minh ngược lại. Áp dụng nhất quán, bạn sẽ thấy số incident dạng Cannot read properties of undefined giảm hẳn, và khi có lỗi thì message sẽ chỉ thẳng vào nguyên nhân thay vì bắt bạn lần ngược stack trace lúc nửa đêm.

Top comments (0)