Ngừng Catch Lỗi Tràn Lan: Dùng Result Pattern Clean Code

Ngừng Catch Lỗi Tràn Lan: Dùng Result Pattern Clean Code

Ngừng bọc try-catch tràn lan làm rối code TypeScript. Khám phá Result Pattern để xử lý lỗi tường minh, an toàn kiểu dữ liệu và sạch đẹp hơn.

Nếu dạo một vòng qua các dự án TypeScript backend hoặc frontend hiện tại, bạn sẽ rất dễ bắt gặp những khối try-catch lồng nhau bọc xung quanh mọi hàm bất đồng bộ (async/await). Việc ném ngoại lệ (throw exceptions) nghe có vẻ tiện lợi khi chúng ta mới học lập trình cơ bản, nhưng nếu lạm dụng ngoại lệ cho các lỗi nghiệp vụ dự đoán trước được, code của bạn sẽ nhanh chóng trở nên rối rắm, che khuất luồng chạy và đánh mất hoàn toàn khả năng kiểm soát kiểu (type safety) của TypeScript.

Trong bài viết này, chúng ta sẽ cùng phân tích lý do tại sao cơ chế try-catch truyền thống lại lộ nhiều nhược điểm trong ứng dụng TypeScript hiện đại, và làm thế nào để áp dụng Result Pattern—mô hình xử lý lỗi mượn từ các ngôn ngữ lập trình chức năng và Rust—giúp code vừa sạch đẹp, vừa an toàn kiểu dữ liệu.

Vấn đề khi dùng Exception để điều khiển luồng nghiệp vụ

Trong các dự án TypeScript thông thường, một signature hàm async trông thường rất gọn gàng và "hứa hẹn":

async function fetchUserProfile(userId: string): Promise<UserProfile> {
  // Có thể throw UserNotFoundError, DatabaseTimeoutError, hoặc UnauthorizedError
}

Chỉ nhìn vào khai báo hàm ở trên, bất kỳ lập trình viên nào gọi fetchUserProfile cũng sẽ nghĩ rằng hàm này luôn trả về một đối tượng UserProfile. Không có bất kỳ cảnh báo nào từ trình biên dịch cho biết hàm này có thể ném ra một lỗi UserNotFoundError hay UnauthorizedError.

Dần dần, để tránh việc ứng dụng bị crash bất ngờ, anh em lập trình viên đành phải bọc mọi lời gọi hàm trong khối try-catch:

try {
  const user = await fetchUserProfile("usr_123");
  renderUserDashboard(user);
} catch (error: unknown) {
  // error ở đây là gì? UserNotFoundError? Lỗi mạng? Lỗi cú pháp?
  // TypeScript bắt buộc kiểu của error phải là 'unknown' hoặc 'any'
  logger.error(error);
}

Cách làm này tạo ra ba vướng mắc lớn trong thực tế:

  1. Lỗi bị che khuất (Invisible Failures): Signature của hàm không nói lên toàn bộ sự thật. Bạn bắt buộc phải đọc sâu vào code bên trong của từng helper function mới biết nó có thể throw ra những loại exception nào.
  2. Mất an toàn kiểu dữ liệu (Type Safety): Trong khối catch, TypeScript coi errorunknown hoặc any. Bạn phải viết hàng loạt câu lệnh instanceof hoặc type guard thủ công để đoán xem điều gì vừa xảy ra.
  3. Phá vỡ luồng điều khiển đơn thuần: Việc throw exception sẽ ngắt đột ngột luồng thực thi và nhảy qua nhiều tầng call stack, làm cho code rất khó trace vị trí thực sự xử lý lỗi.

Result Pattern là gì và hoạt động ra sao?

Lập trình viên xem xét sơ đồ luồng điều khiển được cấu trúc rõ ràng

Triết lý cốt lõi của Result Pattern rất thực dụng: Một lỗi nghiệp vụ dự đoán trước được là một giá trị trả về hợp lệ, không phải một ngoại lệ khẩn cấp cần throw.

Thay vì quăng lỗi vào không gian stack ẩn, hàm sẽ trả về một container object có tên là Result<T, E>. Container này thể hiện một trong hai trạng thái: thành công mang theo dữ liệu (T), hoặc thất bại mang theo thông tin lỗi nghiệp vụ (E).

Chúng ta có thể tự định nghĩa một Result type siêu nhẹ trong TypeScript mà không cần cài thêm bất kỳ thư viện bên ngoài nào:

export type Ok<T> = { readonly ok: true; readonly value: T };
export type Err<E> = { readonly ok: false; readonly error: E };
export type Result<T, E> = Ok<T> | Err<E>;

export const ok = <T>(value: T): Ok<T> => ({ ok: true, value });
export const err = <E>(error: E): Err<E> => ({ ok: false, error });

Nhờ cơ chế Discriminated Union của TypeScript dựa trên thuộc tính boolean ok, khi bạn kiểm tra if (result.ok), TypeScript sẽ tự động thu hẹp kiểu (type narrowing) ở các dòng code tiếp theo mà không cần cast kiểu thủ công.

So sánh thực tế: Trước và sau khi Refactor

Hãy cùng xem xét một luồng thanh toán đơn hàng e-commerce thực tế để thấy rõ sự khác biệt về độ sạch và độ an toàn của code.

Trước: Bọc try-catch thụ động và mơ hồ

async function checkoutOrder(userId: string, cartId: string): Promise<OrderSummary> {
  try {
    const user = await getUser(userId);
    const cart = await getCart(cartId);
    const payment = await chargeCreditCard(user, cart.totalAmount);
    const order = await createOrderRecord(user.id, cart.items, payment.id);
    return order;
  } catch (error) {
    // Không thể biết bước nào trong 4 bước trên bị lỗi ở thời điểm compile
    // Nếu chargeCreditCard lỗi vs getCart lỗi, cách xử lý bị gộp chung vào một chỗ
    throw new Error(`Checkout thất bại: ${error}`);
  }
}

Sau: Luồng xử lý tường minh với Result Pattern

Bây giờ, chúng ta viết lại luồng nghiệp vụ trên bằng cách sử dụng các kiểu lỗi nghiệp vụ rõ ràng cùng với Result:

type CheckoutError = UserNotFoundError | EmptyCartError | PaymentDeclinedError | DatabaseError;

async function checkoutOrder(
  userId: string,
  cartId: string
): Promise<Result<OrderSummary, CheckoutError>> {
  const userRes = await getUser(userId);
  if (!userRes.ok) {
    return err(userRes.error); // Return sớm với lỗi UserNotFoundError rõ ràng
  }

  const cartRes = await getCart(cartId);
  if (!cartRes.ok) {
    return err(cartRes.error); // Return sớm với lỗi EmptyCartError
  }

  const paymentRes = await chargeCreditCard(userRes.value, cartRes.value.totalAmount);
  if (!paymentRes.ok) {
    return err(paymentRes.error); // Return sớm với lỗi PaymentDeclinedError
  }

  const orderRes = await createOrderRecord(
    userRes.value.id,
    cartRes.value.items,
    paymentRes.value.id
  );
  if (!orderRes.ok) {
    return err(orderRes.error);
  }

  return ok(orderRes.value);
}

Hãy nhìn vào cách code ở nơi gọi hàm (caller) xử lý kết quả trả về:

const result = await checkoutOrder("usr_101", "cart_99");

if (!result.ok) {
  // TypeScript biết chính xác result.error chỉ có thể thuộc tập hợp CheckoutError
  switch (result.error.kind) {
    case "PaymentDeclined":
      return showNotification("Thẻ thanh toán bị từ chối. Vui lòng thử lại.");
    case "EmptyCart":
      return showNotification("Giỏ hàng của bạn đang trống.");
    default:
      return showNotification("Đã có lỗi xảy ra. Vui lòng thử lại sau.");
  }
}

// Ở đây TypeScript hiểu chắc chắn result.value là kiểu OrderSummary!
renderOrderReceipt(result.value);

Sự khác biệt là vô cùng rõ rệt. Signature của hàm miêu tả chính xác mọi khả năng xảy ra, gợi ý code của trình soạn thảo hoạt động mượt mà, và lập trình viên không bao giờ bị bỏ quên việc xử lý các trường hợp lỗi.

Khi nào bạn vẫn nên sử dụng Try-Catch?

Áp dụng Result Pattern không có nghĩa là chúng ta "tẩy chay" hoàn toàn try-catch. Thay vào đó, nó giúp phân định ranh giới xử lý lỗi cực kỳ rõ ràng:

  • Dùng Result Pattern cho Lỗi Nghiệp Vụ Dự Đoán Được (Domain Errors): Dữ liệu đầu vào không hợp lệ, không tìm thấy người dùng, tài khoản không đủ tiền, token hết hạn, hoặc hết hàng trong kho.
  • Dùng Try-Catch cho Lỗi Hệ Thống Bất Ngờ (System Crashes): Lỗi tràn bộ nhớ (Out of memory), đứt kết nối mạng đột ngột, sập server database, hoặc lỗi từ thư viện bên thứ ba bị crash.

Khi làm việc với các SDK bên thứ ba hoặc thư viện cũ vốn sử dụng cơ chế throw exception, bạn nên bọc chúng lại ở tầng Infrastructure bằng một helper nhỏ để chuyển đổi exception thành Result:

async function toResult<T>(promise: Promise<T>): Promise<Result<T, Error>> {
  try {
    const value = await promise;
    return ok(value);
  } catch (error) {
    return err(error instanceof Error ? error : new Error(String(error)));
  }
}

Nhờ đó, các exception trôi tự do sẽ bị "chặn" ngay tại ranh giới hạ tầng và được biến đổi thành đối tượng Result an toàn trước khi đi vào logic nghiệp vụ chính của dự án.

Lời khuyên thực chiến cho anh em Developer

Dịch chuyển từ tư duy throw exception sang Result Pattern mang lại những thay đổi rất thực tế cho quy trình phát triển phần mềm:

  • Code Tự Giải Thích (Self-Documenting): Signature của hàm trở thành một hợp đồng đáng tin cậy, thể hiện cả dữ liệu thành công lẫn các kịch bản lỗi.
  • Bắt Lỗi Triệt Để (Exhaustive Checks): Trình kiểm tra kiểu của TypeScript sẽ "nhắc nhở" nếu bạn bỏ sót bất kỳ trường hợp lỗi nào mà hàm có thể trả về.
  • Tối Ưu Hiệu Năng & Stack Trace: Tránh được chi phí tạo stack trace đắt đỏ của exception cho những lỗi nghiệp vụ thông thường.

Hãy bắt đầu thử nghiệm ngay trên một module nhỏ trong dự án tiếp theo bằng cách trả về { ok: true, value } | { ok: false, error }. Bạn và đồng nghiệp chắc chắn sẽ cảm nhận được sự dễ chịu và tự tin hơn rất nhiều khi bảo trì hệ thố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