Effect TS: Quản lý lỗi và Concurrency chuyên nghiệp trong TypeScript

Development tutorial - IT technology blog
Development tutorial - IT technology blog

Vấn đề mà ai code TypeScript lâu năm cũng từng gặp

Bạn có code đoạn này không? Một chuỗi try/catch lồng nhau, Promise.all đặt trong try, rồi lại catch bên ngoài — và không ai trong team chắc chắn lỗi nào có thể xảy ra ở bước nào.

async function processOrder(orderId: string) {
  try {
    const order = await fetchOrder(orderId); // lỗi network?
    try {
      const payment = await chargeCard(order); // lỗi payment?
      await sendEmail(order, payment); // lỗi email?
    } catch (paymentErr) {
      // xử lý riêng... nhưng email lỗi cũng vào đây?
    }
  } catch (err) {
    // err là 'unknown' — không biết type gì
    console.error(err);
  }
}

Code chạy tốt trong dev, nhưng production thì khác. err có type unknown — không rõ đây là NetworkError, PaymentDeclinedError, hay exception bình thường nào đó. TypeScript compiler im lặng hoàn toàn. Lỗi chỉ lộ ra lúc khách hàng gặp sự cố.

Mình từng duy trì một codebase như vậy trong 6 tháng trước khi chuyển sang Effect TS — và sự khác biệt rõ đến mức team không muốn quay lại.

So sánh các approach xử lý lỗi trong TypeScript

Nhìn lại thì có 4 hướng chính — mỗi hướng đều có chỗ đau riêng:

1. try/catch thuần

Cái hay duy nhất: không cần học thêm gì. Nhưng đổi lại — lỗi không có type, dễ bỏ sót catch ở chỗ quan trọng, và khi cần xử lý lỗi qua nhiều bước thì code nhanh chóng trở thành mớ hỗn độn khó trace.

2. Result/Either type (fp-ts, neverthrow)

Ý tưởng cốt lõi: encode lỗi vào return type — Result<Value, Error>. Compiler sẽ nhắc bạn xử lý lỗi thay vì im lặng để nó nổ production. Tốt hơn try/catch một bậc rõ rệt. Nhưng khi cần concurrency — chạy 3 request song song rồi gom kết quả — code bắt đầu phức tạp nhanh.

3. Promise + custom error class

Một pattern mình hay thấy ở các team JS truyền thống là reject với typed error:

class PaymentError extends Error {
  constructor(public code: 'declined' | 'invalid_card', message: string) {
    super(message);
  }
}
// Nhưng Promise<T> không encode lỗi vào type signature
// TypeScript vẫn không nhắc bạn catch PaymentError

Vẫn là “lỗi ngầm” — compiler không bắt buộc bạn handle, và người đọc code sau cũng không biết function này có thể throw gì.

4. Effect TS

Effect encode cả ba chiều vào type signature: Effect<Success, Error, Requirements>. Compiler biết chính xác lỗi nào có thể xảy ra và bắt bạn phải xử lý. Concurrency, retry, timeout — tất cả tích hợp sẵn trong core, không cần thêm thư viện nào.

Phân tích ưu nhược: Effect TS có phù hợp với dự án bạn không?

Ưu điểm thực sự đáng kể

  • Typed errors: Effect<Order, NetworkError | PaymentError, never> — nhìn vào signature biết ngay lỗi nào có thể xảy ra, không cần đọc implementation
  • Composability: chain các Effect lại như LEGO, xử lý lỗi ở từng bước hoặc bubble up có kiểm soát
  • Concurrency built-in: Effect.all, Effect.race, fiber-based scheduling — không cần thêm thư viện
  • Resource safety: Effect.acquireRelease đảm bảo cleanup chạy dù có lỗi hay không (cùng ý tưởng với defer trong Go hay using trong C#)
  • Testability: dependency injection qua Requirements — mock database trong test chỉ cần swap một Layer, không cần monkey-patch hay jest.mock phức tạp

Khi nào KHÔNG nên dùng

  • Script nhỏ, one-off tool — overhead học Effect không worth it
  • Team chưa quen functional programming — learning curve khá dốc trong tuần đầu
  • Project deadline quá gấp — đừng học paradigm mới khi đang chạy nước rút

Mình thường khuyên: dùng Effect cho core business logic (payment, order processing), giữ try/catch cho những utility nhỏ ít ảnh hưởng nếu lỗi.

Chọn cách tiếp cận phù hợp

Rule of thumb mình hay áp dụng:

  • Logic có nhiều loại lỗi khác nhau cần xử lý khác nhau → Effect
  • Cần chạy N task song song với timeout/retry → Effect
  • CRUD đơn giản, một tầng, ít lỗi edge case → try/catch hoặc neverthrow là đủ

Dự án web app gần nhất mình làm có 5 developer. Sau khi chuyển core business logic sang Effect, thời gian review PR giảm khoảng 40% — reviewer không cần đọc implementation để biết function có thể throw lỗi gì. Sau 2 tháng, bug production liên quan đến lỗi chưa handle gần như về 0.

Triển khai thực tế với Effect TS

Cài đặt

npm install effect
# hoặc
pnpm add effect

Effect không cần config đặc biệt, chỉ cần TypeScript 5.0+ với strict: true.

Error handling có type: từ unknown đến cụ thể

Bước đầu tiên — định nghĩa lỗi như data:

import { Effect, Data } from 'effect';

// Define errors as tagged data
class NetworkError extends Data.TaggedError('NetworkError')<{
  message: string;
  statusCode: number;
}> {}

class PaymentDeclinedError extends Data.TaggedError('PaymentDeclinedError')<{
  reason: 'insufficient_funds' | 'invalid_card' | 'expired';
}> {}

// Function signature nói lên tất cả
const fetchOrder = (id: string): Effect.Effect<Order, NetworkError> =>
  Effect.tryPromise({
    try: () => fetch(`/api/orders/${id}`).then(r => r.json()),
    catch: (err) => new NetworkError({
      message: String(err),
      statusCode: 500
    })
  });

const chargeCard = (order: Order): Effect.Effect<Payment, NetworkError | PaymentDeclinedError> =>
  Effect.tryPromise({
    try: () => paymentGateway.charge(order),
    catch: (err: any) => {
      if (err.code === 'card_declined') {
        return new PaymentDeclinedError({ reason: 'insufficient_funds' });
      }
      return new NetworkError({ message: err.message, statusCode: 500 });
    }
  });

Chain và xử lý lỗi từng bước

const processOrder = (orderId: string) =>
  fetchOrder(orderId).pipe(
    Effect.flatMap(order => chargeCard(order)),
    // Chỉ handle PaymentDeclinedError tại đây
    Effect.catchTag('PaymentDeclinedError', (err) =>
      Effect.logWarning(`Payment declined: ${err.reason}`).pipe(
        Effect.flatMap(() => Effect.fail(err)) // re-throw sau khi log
      )
    ),
    // NetworkError vẫn propagate lên caller
  );

// Chạy và xử lý tất cả lỗi còn lại ở tầng trên cùng
const main = processOrder('order-123').pipe(
  Effect.catchAll((err) => {
    // err ở đây có type chính xác: NetworkError | PaymentDeclinedError
    switch (err._tag) {
      case 'NetworkError':
        return Effect.logError(`Network issue: ${err.statusCode}`);
      case 'PaymentDeclinedError':
        return Effect.logError(`Payment declined: ${err.reason}`);
    }
  })
);

Effect.runPromise(main);

Concurrency: chạy song song với kiểm soát

Đây là điểm mình thấy Effect vượt trội rõ nhất so với Promise.all:

import { Effect } from 'effect';

// Chạy 3 task song song, fail ngay nếu bất kỳ task nào fail
const parallel = Effect.all(
  [
    fetchUserProfile(userId),
    fetchUserOrders(userId),
    fetchUserSettings(userId),
  ],
  { concurrency: 'unbounded' } // hoặc giới hạn: { concurrency: 2 }
);

// Race: lấy kết quả từ source nhanh nhất
const fromCache = fetchFromCache(key);
const fromDB = fetchFromDatabase(key);
const result = Effect.race(fromCache, fromDB);

// Retry với exponential backoff
const withRetry = fetchOrder(id).pipe(
  Effect.retry({
    times: 3,
    schedule: Schedule.exponential('100 millis')
  })
);

// Timeout
const withTimeout = fetchOrder(id).pipe(
  Effect.timeout('5 seconds')
);

Resource management: đảm bảo cleanup

import { Effect } from 'effect';

// acquireRelease đảm bảo connection luôn được đóng dù có lỗi hay không
const withDbConnection = Effect.acquireRelease(
  Effect.promise(() => pool.connect()), // acquire
  (conn) => Effect.promise(() => conn.release()) // release — luôn chạy
);

const queryUser = (id: string) =>
  Effect.scoped(
    withDbConnection.pipe(
      Effect.flatMap(conn =>
        Effect.promise(() => conn.query('SELECT * FROM users WHERE id = $1', [id]))
      )
    )
  );

Tips thực chiến từ dự án thật

  • Đừng convert toàn bộ codebase ngay: Bắt đầu với module quan trọng nhất (payment, auth), rồi mở rộng dần. Effect tương thích tốt với Promise thông thường qua Effect.promise()Effect.runPromise().
  • Dùng Effect.gen nếu team không quen pipe/flatMap: Syntax giống async/await hơn, dễ onboard:
const processOrder = (orderId: string) =>
  Effect.gen(function* () {
    const order = yield* fetchOrder(orderId);
    const payment = yield* chargeCard(order);
    yield* sendConfirmationEmail(order, payment);
    return { order, payment };
  });
  • Layer pattern cho dependency injection: Tách interface và implementation bằng Context.Tag + Layer — test inject mock, production inject real implementation. Không cần framework DI riêng.
  • Đọc log Effect cẩn thận: Effect có structured logging built-in (Effect.log, Effect.logError). Dùng nó thay console.log để trace lỗi qua nhiều layer dễ hơn nhiều.

Kết luận thực dụng

Effect TS không giải quyết mọi vấn đề — nhưng với những dự án TypeScript nghiêm túc, nơi lỗi cần được xử lý rõ ràng và concurrency là bắt buộc, nó đánh đúng vào điểm đau nhất của try/catch truyền thống. Type signature trở thành tài liệu sống — bạn biết function có thể fail với lỗi gì mà không cần đọc implementation hay comment.

Tuần đầu sẽ chậm. Tuần thứ ba, bạn sẽ không muốn quay lại cách cũ.

Share: