Triển khai Result Pattern trong TypeScript: Xử lý Lỗi Chuyên nghiệp Không cần try-catch

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Bối cảnh & Lý do: Chi phí Ẩn của try-catch

Hồi mới bắt đầu với TypeScript, tôi viết những hàm trông có vẻ gọn gàng nhưng thực ra là những quả bom nổ chậm. Kiểu như thế này:

async function fetchUserProfile(userId: string) {
  try {
    const res = await fetch(`/api/users/${userId}`);
    const data = await res.json();
    return data;
  } catch (err) {
    console.error(err);
    return null;
  }
}

Signature của hàm nói rằng nó trả về dữ liệu người dùng. Nhưng nó có thể âm thầm trả về null. Caller không hề biết rằng có thể xảy ra lỗi — cho đến khi hệ thống bị nổ tung lúc 2 giờ sáng trong production.

Đây chính là vấn đề cốt lõi của try-catch trong TypeScript: lỗi không hiển thị trong hệ thống kiểu. TypeScript có thể cho bạn biết rất nhiều về code — nhưng nó sẽ không cảnh báo bạn khi quên xử lý trường hợp lỗi, vì lỗi không bao giờ xuất hiện trong kiểu trả về của hàm.

Result Pattern giải quyết vấn đề này bằng cách mã hóa lỗi trực tiếp vào kiểu trả về. Thay vì ném exception hoặc trả về null, hàm trả về hoặc là giá trị thành công hoặc là lỗi có kiểu xác định — và hệ thống kiểu buộc caller phải xử lý cả hai. Không còn bỏ sót nữa.

Trên thực tế, lợi ích này thể hiện rất nhanh. Khoảng 500–1000 dòng code, các nhóm bắt đầu mất dấu xem hàm nào có thể âm thầm thất bại. Result Pattern làm cho kiến thức đó trở nên không thể bỏ qua — nó nằm ngay trong các kiểu dữ liệu.

Result Pattern Trông Như Thế Nào

Ở dạng đơn giản nhất, pattern chỉ là một discriminated union:

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

Một hàm có thể thất bại sẽ trả về Result<User, ApiError> thay vì User | null. Giờ TypeScript biết rằng thất bại là có thể xảy ra. Nó sẽ không cho phép bạn sử dụng giá trị mà không kiểm tra ok trước.

Cài đặt: Chọn Hướng Tiếp cận

Có hai lựa chọn: tự viết Result type nhẹ nhàng, hoặc dùng thư viện có sẵn. Cả hai đều hoạt động — chọn dựa trên mức độ tính năng bạn cần ngay từ đầu.

Lựa chọn A: Tự Xây dựng (Khuyến nghị để Học)

Tạo dự án TypeScript mới nếu bạn chưa có:

mkdir result-pattern-demo
cd result-pattern-demo
npm init -y
npm install typescript ts-node @types/node --save-dev
npx tsc --init

Sau đó tạo file src/result.ts với các kiểu cốt lõi và helper functions:

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

export function ok<T>(value: T): Result<T, never> {
  return { ok: true, value };
}

export function err<E>(error: E): Result<never, E> {
  return { ok: false, error };
}

Chỉ cần hai helper functions — ok()err() — là đủ để bắt đầu trả về Results từ bất kỳ hàm nào.

Lựa chọn B: Dùng neverthrow

Muốn chaining, hỗ trợ async và nhiều tiện ích hơn ngay từ đầu? neverthrow là thư viện Result được sử dụng rộng rãi nhất trong hệ sinh thái TypeScript:

npm install neverthrow

Nó cung cấp cùng pattern ok()err() cộng thêm các tính năng như .map(), .mapErr(), và ResultAsync cho workflow dựa trên promise. Với bài hướng dẫn này, phiên bản tự viết là đủ để hiểu ý tưởng cốt lõi.

Cấu hình: Tích hợp Pattern vào Code Thực tế

Bây giờ đến phần thú vị — viết lại service layer để dùng Results thay vì ném exception.

Bước 1: Định nghĩa Error Types

Một trong những lợi ích lớn nhất của pattern này là typed errors. Thay vì bắt một Error không xác định, bạn định nghĩa chính xác những gì có thể xảy ra sai:

// src/errors.ts
export type ApiError =
  | { type: 'NOT_FOUND';    message: string }
  | { type: 'UNAUTHORIZED'; message: string }
  | { type: 'NETWORK_ERROR'; message: string; statusCode?: number };

Mọi caller giờ đây đều biết trước các trường hợp lỗi cần xử lý. Không còn phải đoán mò nữa.

Bước 2: Viết lại Service Functions

// src/userService.ts
import { Result, ok, err } from './result';
import { ApiError } from './errors';

interface User {
  id: string;
  name: string;
  email: string;
}

async function fetchUser(userId: string): Promise<Result<User, ApiError>> {
  try {
    const res = await fetch(`https://api.example.com/users/${userId}`);

    if (res.status === 404) {
      return err({ type: 'NOT_FOUND', message: `Không tìm thấy người dùng ${userId}` });
    }
    if (res.status === 401) {
      return err({ type: 'UNAUTHORIZED', message: 'Token không hợp lệ hoặc đã hết hạn' });
    }
    if (!res.ok) {
      return err({
        type: 'NETWORK_ERROR',
        message: 'Lỗi API không mong muốn',
        statusCode: res.status,
      });
    }

    const user: User = await res.json();
    return ok(user);
  } catch (e) {
    return err({ type: 'NETWORK_ERROR', message: 'Không thể kết nối đến API' });
  }
}

try-catch vẫn còn ở đây — nhưng nó được cô lập tại ranh giới nơi thứ khó đoán thực sự xảy ra: lời gọi mạng. Phần còn lại của ứng dụng sẽ không bao giờ cần try-catch nữa.

Bước 3: Xử lý Results tại Nơi Gọi Hàm

Đây là lúc mọi thứ trở nên rõ ràng. Caller nhận được một interface sạch sẽ, dễ dự đoán:

// src/index.ts
import { fetchUser } from './userService';

async function main() {
  const result = await fetchUser('user-123');

  if (!result.ok) {
    // TypeScript thu hẹp result.error thành ApiError tại đây
    switch (result.error.type) {
      case 'NOT_FOUND':
        console.log('Hiển thị trang 404:', result.error.message);
        break;
      case 'UNAUTHORIZED':
        console.log('Chuyển hướng đến đăng nhập:', result.error.message);
        break;
      case 'NETWORK_ERROR':
        console.log(`Thử lại sau. Status: ${result.error.statusCode}`);
        break;
    }
    return;
  }

  // TypeScript biết result.value là User tại đây — không cần ép kiểu
  console.log(`Chào mừng, ${result.value.name}!`);
}

main();

Thêm một error type mới vào ApiError sau này, và TypeScript sẽ highlight mọi câu lệnh switch đã quên xử lý nó. Đây là sự an toàn tại thời điểm biên dịch mà bạn đơn giản không thể có được từ try-catch.

Bước 4: Kết hợp Results Giữa Các Tầng

Giả sử bạn fetch một user, rồi fetch đơn hàng của họ. Cả hai đều có thể thất bại. Với Results, việc xâu chuỗi chúng lại rất đơn giản — không cần các khối try-catch lồng nhau:

async function getUserWithOrders(userId: string) {
  const userResult = await fetchUser(userId);
  if (!userResult.ok) return userResult; // truyền lỗi lên trên

  const ordersResult = await fetchOrders(userResult.value.id);
  if (!ordersResult.ok) return ordersResult;

  return ok({
    user: userResult.value,
    orders: ordersResult.value,
  });
}

Mỗi bước chỉ tiếp tục nếu bước trước thành công. Nếu có gì đó thất bại, lỗi sẽ nổi lên như một giá trị có kiểu — không phải một exception có thể bị nuốt trôi ở đâu đó trong call stack.

Kiểm tra & Giám sát: Đảm bảo Mọi Thứ Hoạt động

Chạy Smoke Test Nhanh

Chạy entry point với ts-node để xác minh rằng cả happy path lẫn error path đều hoạt động đúng:

# Chạy script
npx ts-node src/index.ts

# Kết quả mong đợi (happy path):
# Chào mừng, Jane Doe!

# Kết quả mong đợi (không tìm thấy):
# Hiển thị trang 404: Không tìm thấy người dùng user-999

Kiểm tra TypeScript Phát hiện Các Trường hợp Còn thiếu

Thử thêm một error type mới vào ApiError mà không cập nhật câu lệnh switch:

export type ApiError =
  | { type: 'NOT_FOUND';    message: string }
  | { type: 'UNAUTHORIZED'; message: string }
  | { type: 'NETWORK_ERROR'; message: string; statusCode?: number }
  | { type: 'RATE_LIMITED'; message: string; retryAfter: number }; // MỚI

Chạy TypeScript compiler:

npx tsc --noEmit

Với exhaustiveness check mặc định trong switch (như bên dưới), compiler ngay lập tức đánh dấu trường hợp còn thiếu:

function assertNever(x: never): never {
  throw new Error('Trường hợp chưa xử lý: ' + JSON.stringify(x));
}

// Bên trong switch của bạn:
default:
  assertNever(result.error); // Lỗi compile nếu thiếu một trường hợp

Compiler trở thành công cụ kiểm tra exhaustiveness của bạn — nó đánh dấu trường hợp còn thiếu trước khi code được triển khai, mỗi lần, mà không cần reviewer phát hiện.

Logging và Khả năng Quan sát

Lỗi giờ là các plain objects trong luồng trả về. Thêm structured logging trở nên rất đơn giản:

const result = await fetchUser(userId);

if (!result.ok) {
  // Ghi log dạng structured data — dễ dàng gửi đến Datadog, Sentry, v.v.
  console.error(JSON.stringify({
    event: 'fetch_user_failed',
    errorType: result.error.type,
    userId,
    timestamp: new Date().toISOString(),
  }));
}

So sánh điều này với việc bắt một object Error không xác định và cố gắng serialize nó cho một hệ thống logging. Typed errors giúp khả năng quan sát trở nên sạch sẽ hơn đáng kể.

Một Điều Cần Lưu ý

Result Pattern không thay thế try-catch ở mọi nơi — nó thay thế sự lan truyền không kiểm soát. Bạn vẫn cần try-catch tại ranh giới ngoài cùng: lời gọi mạng, truy vấn database, đọc file. Bắt tại đó, chuyển đổi exception thành Result, và để Results chảy sạch sẽ qua phần còn lại của ứng dụng.

Một quy tắc đơn giản: nếu một hàm chạm vào I/O bên ngoài, bọc nó trong try-catch và trả về Result. Nếu nó chỉ gọi các hàm của bạn, hãy truyền Results — không cần try-catch.

Khi được sử dụng nhất quán, Result Pattern làm cho code dễ đọc hơn, dễ kiểm tra hơn, và khó bị lỗi âm thầm hơn nhiều. Hệ thống kiểu thực thi kỷ luật mà các code review thường xuyên bỏ sót.

Share: