1. Monads là gì?

Ghi chú: Monad là khái niệm từ lập trình hàm / lý thuyết hạng mục, không thuộc bộ 23 pattern Gang of Four. Bài viết dùng Maybe/Either để minh họa cách áp dụng tinh thần monad trong JavaScript/TypeScript.

Monads trong ngữ cảnh thực hành giúp xử lý các giá trị có thể thiếu (null/undefined) và chuỗi tác vụ có hiệu ứng (ví dụ lỗi, async) một cách có kiểm soát. Pattern này bao gồm ba thành phần chính:

  • Unit (return): Đưa một giá trị vào trong monad
  • Bind (flatMap): Áp dụng một hàm lên giá trị trong monad
  • Join: Kết hợp các monad lồng nhau

2. Triển khai Monads trong JavaScript

2.1 Maybe Monad

class Maybe {
  constructor(value) {
    this.value = value;
  }

  static of(value) {
    return new Maybe(value);
  }

  static nothing() {
    return new Maybe(null);
  }

  isNothing() {
    return this.value === null || this.value === undefined;
  }

  map(fn) {
    if (this.isNothing()) {
      return Maybe.nothing();
    }
    return Maybe.of(fn(this.value));
  }

  flatMap(fn) {
    if (this.isNothing()) {
      return Maybe.nothing();
    }
    return fn(this.value);
  }

  getOrElse(defaultValue) {
    if (this.isNothing()) {
      return defaultValue;
    }
    return this.value;
  }
}

// Ví dụ sử dụng
const user = {
  name: "John",
  address: {
    street: "123 Main St",
    city: "New York",
  },
};

const getUserCity = (user) =>
  Maybe.of(user)
    .map((u) => u.address)
    .map((addr) => addr.city)
    .getOrElse("Unknown");

console.log(getUserCity(user)); // "New York"
console.log(getUserCity(null)); // "Unknown"

2.2 Either Monad

class Either {
  constructor(value, isLeft) {
    this.value = value;
    this.isLeft = isLeft;
  }

  static left(value) {
    return new Either(value, true);
  }

  static right(value) {
    return new Either(value, false);
  }

  map(fn) {
    if (this.isLeft) {
      return Either.left(this.value);
    }
    return Either.right(fn(this.value));
  }

  flatMap(fn) {
    if (this.isLeft) {
      return Either.left(this.value);
    }
    return fn(this.value);
  }

  fold(leftFn, rightFn) {
    if (this.isLeft) {
      return leftFn(this.value);
    }
    return rightFn(this.value);
  }
}

// Ví dụ sử dụng
const divide = (a, b) => {
  if (b === 0) {
    return Either.left("Division by zero");
  }
  return Either.right(a / b);
};

const result = divide(10, 2)
  .map((x) => x * 2)
  .fold(
    (error) => `Error: ${error}`,
    (value) => `Result: ${value}`
  );

console.log(result); // "Result: 10"

3. Triển khai Monads trong TypeScript

3.1 Maybe Monad với TypeScript

class Maybe<T> {
  private constructor(private value: T | null) {}

  static of<T>(value: T): Maybe<T> {
    return new Maybe(value);
  }

  static nothing<T>(): Maybe<T> {
    return new Maybe<T>(null);
  }

  isNothing(): boolean {
    return this.value === null || this.value === undefined;
  }

  map<U>(fn: (value: T) => U): Maybe<U> {
    if (this.isNothing()) {
      return Maybe.nothing<U>();
    }
    return Maybe.of(fn(this.value!));
  }

  flatMap<U>(fn: (value: T) => Maybe<U>): Maybe<U> {
    if (this.isNothing()) {
      return Maybe.nothing<U>();
    }
    return fn(this.value!);
  }

  getOrElse(defaultValue: T): T {
    if (this.isNothing()) {
      return defaultValue;
    }
    return this.value!;
  }
}

// Ví dụ sử dụng
interface User {
  name: string;
  address?: {
    street: string;
    city: string;
  };
}

const getUserCity = (user: User | null): string =>
  Maybe.of(user)
    .map((u) => u.address)
    .map((addr) => addr.city)
    .getOrElse("Unknown");

const user: User = {
  name: "John",
  address: {
    street: "123 Main St",
    city: "New York",
  },
};

console.log(getUserCity(user)); // "New York"
console.log(getUserCity(null)); // "Unknown"

3.2 Either Monad với TypeScript

class Either<L, R> {
  private constructor(
    private value: L | R,
    private isLeft: boolean
  ) {}

  static left<L, R>(value: L): Either<L, R> {
    return new Either(value, true);
  }

  static right<L, R>(value: R): Either<L, R> {
    return new Either(value, false);
  }

  map<U>(fn: (value: R) => U): Either<L, U> {
    if (this.isLeft) {
      return Either.left<L, U>(this.value as L);
    }
    return Either.right<L, U>(fn(this.value as R));
  }

  flatMap<U>(fn: (value: R) => Either<L, U>): Either<L, U> {
    if (this.isLeft) {
      return Either.left<L, U>(this.value as L);
    }
    return fn(this.value as R);
  }

  fold<U>(leftFn: (value: L) => U, rightFn: (value: R) => U): U {
    if (this.isLeft) {
      return leftFn(this.value as L);
    }
    return rightFn(this.value as R);
  }
}

// Ví dụ sử dụng
type ValidationError = string;
type User = {
  name: string;
  age: number;
};

const validateUser = (user: User): Either<ValidationError, User> => {
  if (!user.name) {
    return Either.left<ValidationError, User>("Name is required");
  }
  if (user.age < 0) {
    return Either.left<ValidationError, User>("Age must be positive");
  }
  return Either.right<ValidationError, User>(user);
};

const result = validateUser({ name: "John", age: 30 })
  .map((user) => ({ ...user, age: user.age + 1 }))
  .fold(
    (error) => `Error: ${error}`,
    (user) => `Valid user: ${user.name}, age: ${user.age}`
  );

console.log(result); // "Valid user: John, age: 31"

4. Dùng trong thực tế: API có thể lỗi

interface ApiResponse<T> {
  data: T;
  error?: string;
}

// API Client với Maybe Monad
class ApiClient {
  static async fetch<T>(url: string): Promise<Maybe<T>> {
    try {
      const response = await fetch(url);
      if (!response.ok) {
        return Maybe.nothing<T>();
      }
      const data = await response.json();
      return Maybe.of<T>(data);
    } catch (error) {
      return Maybe.nothing<T>();
    }
  }
}

// Ví dụ sử dụng
interface Post {
  id: number;
  title: string;
  content: string;
}

const getPost = async (id: number): Promise<string> => {
  return ApiClient.fetch<Post>(`/api/posts/${id}`)
    .map((post) => post.title)
    .getOrElse("Post not found");
};

// Sử dụng
const postTitle = await getPost(1);
console.log(postTitle); // "Post title" hoặc "Post not found"

Ví dụ này có một điểm hơi nguy hiểm: Maybe làm mất thông tin lỗi. Với màn hình đọc public post thì “không thấy dữ liệu” có thể đủ. Nhưng với checkout, payment, hoặc sync job, bạn thường cần biết lỗi là 404, timeout, hay schema response đổi để còn log và alert.

Trong production, mình thường ưu tiên Either hoặc một kiểu Result rõ ràng hơn:

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

type ApiError =
  | { type: "not_found"; status: 404 }
  | { type: "network"; cause: unknown }
  | { type: "invalid_json"; cause: unknown };

async function fetchJson<T>(url: string): Promise<Result<ApiError, T>> {
  try {
    const response = await fetch(url);

    if (response.status === 404) {
      return { ok: false, error: { type: "not_found", status: 404 } };
    }

    const value = (await response.json()) as T;
    return { ok: true, value };
  } catch (cause) {
    return { ok: false, error: { type: "network", cause } };
  }
}

Đây vẫn là tinh thần monad, nhưng đọc với team JavaScript bình thường dễ hơn nhiều so với một class Either tự chế. Quan trọng nhất là lỗi không bị nuốt mất.

5. Checklist khi đưa vào codebase

  • Dùng Maybe cho dữ liệu optional, không dùng để giấu lỗi cần observability.
  • Dùng Either / Result khi caller cần phân biệt nguyên nhân thất bại.
  • Log lỗi ở biên hệ thống, đừng log trong từng bước map/flatMap.
  • Không trộn exception và Either bừa bãi trong cùng một flow.
  • Viết test cho cả nhánh RightLeft, nhất là khi chain nhiều bước.

Một test nhỏ thường đủ bắt phần lớn bug mapping:

const parseAge = (input: string): Result<string, number> => {
  const age = Number(input);
  return Number.isFinite(age)
    ? { ok: true, value: age }
    : { ok: false, error: "age_invalid" };
};

const result = parseAge("42");

if (!result.ok) {
  throw new Error("Expected valid age");
}

console.assert(result.value === 42);

6. Trade-off cần nhớ

6.1 Ưu điểm

  • Xử lý null an toàn: Giúp xử lý các giá trị null một cách an toàn
  • Composition: Cho phép kết hợp các tác vụ một cách dễ dàng
  • Type safety: Đảm bảo type safety trong TypeScript
  • Error handling: Xử lý lỗi một cách rõ ràng và có cấu trúc

6.2 Nhược điểm

  • Độ phức tạp: Có thể làm code phức tạp hơn nếu sử dụng không đúng cách
  • Learning curve: Yêu cầu hiểu biết về lập trình hàm
  • Verbose: Có thể làm code dài hơn trong một số trường hợp
  • Debugging: Có thể khó debug hơn do tính chất trừu tượng

7. Khi nào nên dùng Monads?

Monads phù hợp khi:

  • Cần xử lý các giá trị có thể null
  • Cần xử lý lỗi một cách có cấu trúc
  • Cần kết hợp nhiều tác vụ có thể thất bại
  • Cần đảm bảo type safety trong TypeScript

Không nên dùng chỉ để làm code “FP hơn”. Nếu optional chaining, early return, hoặc discriminated union thuần TypeScript đã đủ rõ, cứ dùng cách đơn giản đó.