Ngừng ném Exception: Xử lý lỗi an toàn trong TypeScript

Ngừng ném Exception: Xử lý lỗi an toàn trong TypeScript

Quên try/catch đi. Học cách áp dụng pattern Result và discriminated unions trong TypeScript để quản lý lỗi an toàn, dễ đoán và type-safe hơn.

Bạn đã bao giờ gọi một hàm trong dự án TypeScript và thấp thỏm tự hỏi: "Liệu cái hàm này có nổ tung (crash) giữa chừng không nhỉ?". Trừ khi bạn chui vào đọc mã nguồn của từng hàm hoặc phụ thuộc vào document, bạn gần như không thể biết chắc. Hệ thống type (kiểu dữ liệu) của TypeScript rất tuyệt vời trong việc đảm bảo kết quả trả về khi mọi thứ suôn sẻ. Nhưng khi có lỗi xảy ra? Nó lại hoàn toàn "im hơi lặng tiếng".

Cách phổ biến nhất để xử lý lỗi trong JavaScript/TypeScript là dùng từ khóa throw kết hợp với khối try/catch. Dù đây là tính năng cốt lõi của ngôn ngữ, nhưng việc lạm dụng nó cho các logic nghiệp vụ (business logic) thông thường sẽ khiến code trở nên khó đoán và khó bảo trì. Hôm nay, tôi muốn chia sẻ một cách tiếp cận thực dụng hơn, đảm bảo type-safe 100%, được lấy cảm hứng từ các ngôn ngữ như Go hay Rust: xem lỗi như một giá trị trả về bình thường (Errors as Values).

Góc khuất của try/catch

Hãy xem xét một kịch bản điển hình: bạn viết một hàm để lấy thông tin người dùng từ database.

async function getUserProfile(userId: string): Promise<UserProfile> {
  const user = await db.users.find(userId);
  if (!user) {
    throw new UserNotFoundError(`User ${userId} not found`);
  }
  return user;
}

Thoạt nhìn, đoạn code này trông rất bình thường. Type signature (chữ ký hàm) khai báo rõ ràng nó trả về một Promise<UserProfile>. Nhưng có một "tác dụng phụ" (side effect) ẩn giấu ở đây: nó có thể ném ra UserNotFoundError. Chữ ký hàm không hề cảnh báo cho người gọi về rủi ro này. Nếu lập trình viên vô tình quên bọc lời gọi hàm trong try/catch, ứng dụng của bạn có thể sẽ sập (crash) ở runtime.

Tệ hơn nữa, ngay cả khi bạn nhớ catch lỗi, TypeScript lại ép bạn phải làm việc với kiểu unknown.

try {
  const profile = await getUserProfile("123");
  renderProfile(profile);
} catch (error) {
  // 'error' mặc định mang kiểu 'unknown' trong các bản TS mới
  if (error instanceof UserNotFoundError) {
    showUIError(error.message);
  } else {
    reportToCrashlytics(error);
  }
}

Bên trong khối catch, sự bảo vệ của type system gần như bốc hơi. Bạn phải viết các đoạn check instanceof cồng kềnh hoặc dùng type guards chỉ để đọc được cái message báo lỗi một cách an toàn. Khi dự án lớn lên, các khối try/catch bắt đầu lồng nhau chằng chịt, khiến luồng thực thi (control flow) trở nên rối rắm.

Cách tiếp cận tốt hơn: Lỗi cũng là Giá trị (Errors as Values)

Mớ dây cáp rối rắm đại diện cho code phức tạp

Chuyện gì sẽ xảy ra nếu chúng ta ngừng xem các lỗi nghiệp vụ dự tính được như những ngoại lệ chết người? Trong các ngôn ngữ như Go hay Rust, bạn không "quăng" một lỗi kiểu "không tìm thấy người dùng". Thay vào đó, bạn trả nó về như một giá trị bình thường, song song với dữ liệu thành công.

Chúng ta có thể mang sự dễ đoán này vào TypeScript bằng một tính năng gọi là Discriminated Unions (Union có phân loại). Bằng cách định nghĩa một type Result chuẩn mực, ta có thể buộc hệ thống type phải nhận thức rõ cả hai trạng thái: thành công và thất bại.

Hãy định nghĩa một generic type Result đơn giản:

export type Success<T> = { 
  ok: true; 
  value: T; 
};

export type Failure<E> = { 
  ok: false; 
  error: E; 
};

export type Result<T, E = Error> = Success<T> | Failure<E>;

Đoạn type tiện ích nhỏ xíu này sẽ thay đổi cục diện. Thuộc tính ok đóng vai trò là cờ phân loại (discriminator). Nếu oktrue, TypeScript hiểu ngay rằng thuộc tính value có tồn tại. Nếu okfalse, nó hiểu chắc chắn sẽ có error.

Áp dụng pattern Result vào thực tế

Dụng cụ được sắp xếp ngăn nắp đại diện cho kiến trúc sạch

Giờ hãy viết lại hàm fetch database lúc nãy bằng type Result mới của chúng ta. Thay vì throw ngoại lệ, ta sẽ chủ động return về một object thất bại.

// Định nghĩa các loại lỗi nghiệp vụ cụ thể
type UserError = 
  | { type: 'NOT_FOUND'; message: string }
  | { type: 'BANNED'; reason: string };

async function getUserProfile(userId: string): Promise<Result<UserProfile, UserError>> {
  try {
    const user = await db.users.find(userId);
    
    if (!user) {
      return { 
        ok: false, 
        error: { type: 'NOT_FOUND', message: `Không tìm thấy user ${userId}` } 
      };
    }
    
    if (user.status === 'banned') {
      return {
        ok: false,
        error: { type: 'BANNED', reason: user.banReason }
      };
    }
    
    return { ok: true, value: user };
  } catch (err) {
    // Chúng ta chỉ throw đối với các lỗi hệ thống thật sự bất ngờ
    throw new Error("Mất kết nối database");
  }
}

Hãy nhìn vào chữ ký hàm lúc này. Nó cực kỳ trung thực. Nó đang tuyên bố với người gọi rằng: "Cuối cùng thì tôi sẽ trả cho bạn hoặc là UserProfile, hoặc là một cái UserError". Không có một cú lừa ngầm nào cả.

Khi bạn sử dụng hàm này, trải nghiệm code (DX) sẽ sướng hơn hẳn. TypeScript sẽ "bắt tay chỉ việc", từ chối không cho bạn đụng vào dữ liệu user nếu bạn chưa xử lý khả năng xảy ra lỗi.

const result = await getUserProfile("123");

// Lỗi TypeScript: Property 'value' không tồn tại trên kiểu 'Result<UserProfile, UserError>'
// console.log(result.value); 

if (!result.ok) {
  // Nhờ Discriminated Unions, TypeScript tự biết 'error' có tồn tại ở nhánh này
  switch (result.error.type) {
    case 'NOT_FOUND':
      return showNotFoundPage();
    case 'BANNED':
      return showBannedOverlay(result.error.reason);
  }
}

// Vượt qua được câu lệnh if trên, TypeScript tự động thu hẹp type (type narrowing)!
// Nó dám chắc 100% 'result' lúc này là Success<UserProfile>
renderProfile(result.value);

Để ý xem đoạn code này phẳng (flat) và dễ đọc đến nhường nào. Chúng ta đã loại bỏ hoàn toàn các scope try/catch lồng nhau. Bằng cách dùng kỹ thuật early return (trả về sớm) để lo liệu các trường hợp xấu, phần thân chính của hàm giờ chỉ tập trung vào "con đường màu hồng" (happy path). Quan trọng nhất, bạn có 100% type safety và auto-complete ngay bên trong logic xử lý lỗi của mình.

Lỗi nghiệp vụ (Domain) vs. Lỗi hệ thống bất ngờ (Fatal)

Một câu hỏi rất phổ biến khi tôi giới thiệu pattern này là: "Vậy từ nay tôi không bao giờ được dùng throw nữa à?".

Không hề. Mục tiêu không phải là cấm tuyệt đối việc quăng exception; mà là trả nó về đúng với chức năng nguyên thủy: xử lý những tình huống ngoại lệ, không thể phục hồi.

Cá nhân tôi thích chia lỗi thành hai nhóm chính:

  1. Lỗi nghiệp vụ dự tính được (Expected Domain Errors): Chẳng hạn như người dùng nhập sai form, tài khoản không đủ số dư, email bị trùng, hay một tài nguyên không tồn tại. Đây là một phần của luồng logic kinh doanh bình thường. Hệ thống hoàn toàn có thể phục hồi và hiển thị một câu thông báo lịch sự cho người dùng. Bạn nên return những lỗi này bằng kiểu Result.
  2. Lỗi hệ thống chết người (Unexpected Fatal Errors): Chẳng hạn như server database đột ngột sập, cấu hình biến môi trường bị thiếu, ứng dụng cạn kiệt RAM, hoặc một bug cú pháp bên trong một thư viện bên thứ 3. Ứng dụng của bạn khó lòng phục hồi êm đẹp từ những lỗi này. Khi đó, việc throw exception là hoàn toàn hợp lý, để một middleware hay error boundary ở vòng ngoài cùng tóm gọn chúng, log lên hệ thống giám sát (monitoring) và trả về trang báo lỗi 500 chung chung.

Kết luận

Việc áp dụng pattern Result đòi hỏi bạn phải thay đổi tư duy một chút, nhất là nếu bạn đã quen viết JavaScript truyền thống nhiều năm nay. Nó ép bạn phải minh bạch và rõ ràng ngay từ đầu về những rủi ro thất bại của từng hàm.

Nhưng thành quả nhận lại là khổng lồ. Bằng cách đưa lỗi vào bên trong hệ thống type, bạn biến codebase của mình thành một hệ thống tự làm tài liệu (self-documenting). Bạn sẽ loại bỏ được cả một rổ bug ở runtime sinh ra từ việc quên handle lỗi. Đồng nghiệp của bạn (và cả chính bạn vài tháng sau) sẽ thầm cảm ơn vì sự dễ đoán này. Lần tới, khi tay bạn định gõ throw new Error(...) cho một quy tắc business đơn giản, hãy dừng lại một nhịp và tự hỏi: liệu dùng giá trị trả về có tốt hơn không?

NT

viết bởi

Nguyên Tech

0

Phản hồi

Đang tải bình luận…

Lattice.

Một không gian để viết dài, đọc chậm, và trò chuyện thật — không thuật toán, không quảng cáo.

© 2026 · Lattice · Đà Nẵng (16°03′ N, 108°12′ E) · v0.1 · system + ink + indigo