Mở rộng TypeScript: Vượt xa Try-Catch với Effect TS

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

Vượt qua sự bất định của Try-Catch

Hầu hết các đội ngũ TypeScript cuối cùng đều gặp bế tắc với async/await tiêu chuẩn. Chúng ta bao bọc mã nguồn trong các khối try/catch, nhưng vì các lỗi trong JavaScript được định kiểu là any, chúng ta mất sạch tính an toàn về kiểu (type safety) ngay khi có sự cố xảy ra. Trong môi trường doanh nghiệp quan trọng, việc áp dụng phương pháp triển khai Result Pattern trong TypeScript là bước đầu để giải quyết vấn đề, nhưng Effect TS còn đưa nó lên một tầm cao mới.

Hãy coi Effect TS không chỉ là một thư viện, mà là một thư viện chuẩn cho những ‘mảnh ghép còn thiếu’ của TypeScript. Nó coi mọi hành động như một giá trị. Điều này có nghĩa là bạn có thể kiểm tra, tính thời gian, thử lại (retry) hoặc hủy bỏ bất kỳ hoạt động nào mà không cần thay đổi logic cốt lõi. Theo kinh nghiệm của tôi khi chuyển đổi một hệ thống fintech backend phức tạp sang Effect, kết hợp với các nguyên tắc Domain-Driven Design trong TypeScript, chúng tôi đã thấy tỷ lệ ngoại lệ runtime không được xử lý giảm 60% chỉ trong hai tháng đầu tiên.

Bắt đầu nhanh: Chạy Effect đầu tiên của bạn (5 phút)

Hãy bỏ qua lý thuyết trừu tượng và bắt đầu chạy code. Effect là một gói duy nhất thay thế cho nhiều loại middleware khác nhau. Cài đặt nó thông qua trình quản lý gói ưa thích của bạn:

npm install effect

Một Effect về cơ bản là một bản thiết kế (blueprint). Nó mô tả những gì sẽ xảy ra nhưng không thực thi cho đến khi bạn yêu cầu rõ ràng. Sự tách biệt giữa định nghĩa và thực thi này là điều làm cho mã nguồn trở nên cực kỳ dễ đoán. Đây là cách bạn tạo một Effect đồng bộ cơ bản:

import { Effect } from "effect";

// 1. Định nghĩa bản thiết kế
const program = Effect.sync(() => {
  console.log("Effect đang chạy!");
  return 42;
});

// 2. Thực thi chương trình
const result = Effect.runSync(program);
console.log(result); // Kết quả: 42

Đối với các ứng dụng thực tế, bạn sẽ chủ yếu làm việc với các tác vụ bất đồng bộ. Effect xử lý việc này một cách mượt mà với runPromise, cho phép nó cùng tồn tại với mã nguồn hiện tại của bạn:

const asyncProgram = Effect.promise(() => 
  Promise.resolve("Đã nhận dữ liệu từ API")
);

Effect.runPromise(asyncProgram).then(console.log);

Mô hình Ba lỗ (Three-Hole): Effect<Success, Error, Requirements>

Phép màu của hệ sinh thái này nằm ở kiểu dữ liệu Effect<A, E, R>. Nếu bạn làm chủ được ba tham số này, bạn sẽ làm chủ được thư viện. Nó thường được gọi là mô hình ‘Ba lỗ’ vì nó buộc bạn phải rõ ràng về mọi khía cạnh của hàm:

  • A (Success): Giá trị trả về là gì? (ví dụ: User hoặc string).
  • E (Error): Chính xác thì điều gì có thể xảy ra sai sót? Không giống như Promise<any>, tham số này liệt kê các kiểu lỗi cụ thể.
  • R (Requirements): Cần những gì để chạy? Đây có thể là một kết nối cơ sở dữ liệu hoặc một API key.

Hãy xem xét một hàm lấy thông tin người dùng. Trong TypeScript tiêu chuẩn, signature của hàm che giấu các rủi ro. Trong Effect, signature kể toàn bộ câu chuyện:

interface User { id: number; name: string; }
class DatabaseError { readonly _tag = "DatabaseError"; }

// Signature cho thấy: Trả về User, có thể thất bại with DatabaseError, không cần context
const getUser = (id: number): Effect.Effect<User, DatabaseError, never> => {
  return Effect.succeed({ id, name: "Jane Doe" });
};

Sự minh bạch này là một thắng lợi lớn cho việc debugging. Khi một dịch vụ gặp lỗi, IDE của bạn sẽ cho bạn biết chính xác kiểu lỗi nào bạn đã quên xử lý.

Khả năng phục hồi mặc định: Retries và Logic

Sự cố mạng chập chờn là nỗi ám ảnh của phần mềm doanh nghiệp. Thông thường, việc thêm logic thử lại (retry) bao gồm các vòng lặp lộn xộn hoặc các wrapper của bên thứ ba. Effect biến khả năng phục hồi thành một tính năng cốt lõi, giúp tăng cường độ ổn định cho Microservices mà không cần cấu hình phức tạp.

Giả sử bạn có một API bên thứ ba không ổn định. Bạn có thể bao bọc nó và áp dụng một chính sách retry tinh vi chỉ trong một dòng mã:

import { Schedule } from "effect";

const flakyCall = Effect.tryPromise({
  try: () => fetch("https://api.stripe.com/v1/charges").then(res => res.json()),
  catch: () => new Error("Hết thời gian kết nối mạng")
});

// Thử lại 3 lần, đợi 1 giây giữa mỗi lần thử
const resilientCall = Effect.retry(
  flakyCall,
  Schedule.spaced("1 second").pipe(Schedule.recurs(3))
);

Nó sạch sẽ hơn và an toàn hơn đáng kể. Vì các lỗi đã được định kiểu, bạn có thể xử lý chi tiết. Bạn có thể chọn retry khi gặp lỗi 503 Service Unavailable nhưng thất bại ngay lập tức với lỗi 401 Unauthorized để tiết kiệm tài nguyên.

Dependency Injection gọn nhẹ

Tham số R xử lý Dependency Injection (DI) mà không cần các framework nặng nề hay decorator. Bạn định nghĩa các ‘Tag’ cho các dịch vụ của mình và cung cấp các ‘Layer’ để đáp ứng chúng.

import { Context, Layer } from "effect";

class ConfigService extends Context.Tag("ConfigService")<
  ConfigService,
  { readonly getUrl: () => Effect.Effect<string> }
>() {}

const ConfigLive = Layer.succeed(
  ConfigService,
  { getUrl: () => Effect.succeed("https://production-api.com") }
);

const program = ConfigService.pipe(
  Effect.flatMap((service) => service.getUrl())
);

Cách tiếp cận này giúp việc kiểm thử trở nên tầm thường. Bạn có thể hoán đổi ConfigLive bằng một layer ConfigTest trong bộ test Vitest của mình mà không cần chạm vào logic nghiệp vụ. Không còn việc giả lập (mocking) thủ công các module toàn cục nữa.

Lời khuyên thực tế khi áp dụng vào Production

Bạn không cần phải viết lại toàn bộ kiến trúc theo mô hình Vertical Slice Architecture chỉ sau một đêm để thấy được lợi ích. Đây là cách tôi khuyên bạn nên giới thiệu Effect cho một đội ngũ hiện có:

  1. Nhắm vào các ‘Hàm lá’ (Leaf Functions): Bắt đầu bằng cách bao bọc các lời gọi API không ổn định nhất hoặc các hoạt động hệ thống tệp phức tạp vào các Effect.
  2. Tận dụng Pipe: Sử dụng phương thức .pipe(). Nó chuyển đổi các lời gọi hàm lồng nhau sâu thành một chuỗi các bước dễ đọc từ trên xuống dưới.
  3. Gắn Tag cho lỗi: Tránh sử dụng các chuỗi (string) đơn giản cho lỗi. Sử dụng các đối tượng có trường _tag để bạn có thể sử dụng Effect.catchTag nhằm xử lý các thất bại cụ thể trong khi để các lỗi khác nổi (bubble up) lên trên.
  4. Sử dụng hệ sinh thái: Nếu bạn cần xác thực dữ liệu, hãy dùng @effect/schema hoặc tìm hiểu cách làm chủ Zod trong TypeScript để có cái nhìn tương quan về validation schema. Chúng chia sẻ cùng một logic nền tảng.

Đường cong học tập là có thật, nhưng nó sẽ sớm mang lại thành quả. Trong vòng vài tuần, các bug của bạn sẽ trở nên dễ truy vết hơn và các định nghĩa kiểu của bạn thực sự bắt đầu bảo vệ bạn khỏi các sự cố production.

Tổng kết

Effect TS mang lại cấu trúc cho sự hỗn loạn vốn có của phát triển web. Bằng cách làm cho các lỗi và dependency trở nên rõ ràng, nó biến ‘lập trình phòng vệ’ thành một tính năng tích hợp sẵn trong hệ thống kiểu của bạn. Đối với bất kỳ ai đang xây dựng các ứng dụng TypeScript quan trọng, làm chủ Effect là con đường nhanh nhất để chuyển từ việc viết script sang kỹ thuật hóa các hệ thống bền bỉ.

Share: