Clean Code với Lập trình Hàm: Currying, Pipe và Composition trong JS/TS

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

Vấn đề “Sợi len rối”

Tôi đã từng dành trọn ba ngày chỉ để gỡ lỗi một tệp duy nhất dài 2.000 dòng, nơi một biến toàn cục bị thay đổi bởi năm hàm khác nhau. Nó giống như việc cố gắng gỡ rối một hộp đèn Giáng sinh cũ trong bóng tối vậy. Mỗi khi tôi sửa xong một lỗi ở góc này, hai lỗi khác lại xuất hiện ở nơi khác. Đó là khoảnh khắc tôi nhận ra rằng lập trình mệnh lệnh (imperative programming) — dù rất tuyệt cho các script nhanh — thường sụp đổ dưới sức nặng của một ứng dụng quy mô lớn.

Lập trình hàm (Functional Programming – FP) không phải là một lý thuyết hàn lâm chỉ dành cho những người đam mê Haskell. Nó là một bộ công cụ thực tế để quản lý luồng dữ liệu. Thay vì quản lý vi mô trạng thái của máy tính theo từng bước, bạn mô tả cách dữ liệu nên được biến đổi. Theo kinh nghiệm của tôi, việc chuyển hướng sang tư duy này là cách hiệu quả nhất để xây dựng các hệ thống không bị lỗi mỗi khi bạn chạm vào chúng.

Bằng cách áp dụng Currying, Pipe và Composition, bạn sẽ thay thế “Code mỳ Ý” (Spaghetti Code) bằng kiến trúc “Khối Lego”. Mỗi hàm trở thành một đơn vị nhỏ, dễ đoán và độc lập. Hãy cùng xem cách triển khai các mẫu (pattern) này trong JavaScript và TypeScript để giải cứu sự tỉnh táo của bạn.

Thiết lập để thành công

Bạn không cần một thư viện khổng lồ để bắt đầu sử dụng FP. Tuy nhiên, tôi cực kỳ đề xuất TypeScript. Hệ thống kiểu dữ liệu của nó đóng vai trò như một thanh chắn an toàn, đảm bảo rằng đầu ra của hàm A thực sự khớp với đầu vào của hàm B. Điều này giúp ngăn chặn những lỗi undefined is not a function phiền toái trước khi chúng kịp xuất hiện ở môi trường thực tế (production).

Để có một không gian thử nghiệm, hãy khởi tạo một dự án TypeScript sạch trong terminal của bạn:

# Tạo thư mục dự án
mkdir fp-lab && cd fp-lab

# Thiết lập npm nhanh
npm init -y

# Cài đặt các gói thiết yếu
npm install typescript ts-node @types/node --save-dev

# Tạo file cấu hình
npx tsc --init

Tạo một tệp index.ts và sử dụng ts-node để chạy code. Thiết lập nhẹ nhàng này hoàn hảo để phác thảo logic mà không tốn công sức thiết lập một quy trình xây dựng (build pipeline) đầy đủ.

Các tiện ích cốt lõi

Chúng ta sẽ xây dựng các công cụ cốt lõi từ đầu. Hiểu được lý do đằng sau các mẫu này giúp việc sử dụng chúng trong các dự án thực tế trở nên dễ dàng hơn nhiều.

1. Currying: Điền trước logic của bạn

Currying chuyển đổi một hàm có nhiều đối số thành một chuỗi các hàm, mỗi hàm chỉ nhận một đối số tại một thời điểm. Nó hoàn hảo để tạo ra các công cụ chuyên dụng từ những công cụ tổng quát. Hãy tưởng tượng bạn đang xây dựng một hệ thống thông báo. Bạn không muốn phải truyền mức độ nghiêm trọng mỗi khi ghi nhật ký một thông báo.

// Phiên bản tổng quát
const log = (level: string, message: string) => {
  console.log(`[${new Date().toISOString()}] [${level}] ${message}`);
};

// Phiên bản curried
const curriedLog = (level: string) => (message: string) => {
  console.log(`[${level}] ${message}`);
};

const criticalError = curriedLog("CRITICAL");
const userNotice = curriedLog("NOTICE");

criticalError("Mất kết nối DB!"); // [CRITICAL] Mất kết nối DB!
userNotice("Người dùng đã đăng nhập.");      // [NOTICE] Người dùng đã đăng nhập.

2. Function Composition: Xây dựng chuỗi liên kết

Composition là “người anh em” nặng về toán học của FP. Nó kết hợp nhiều hàm để compose(f, g)(x) hoạt động giống như f(g(x)). Dữ liệu luân chuyển từ phải sang trái, điều này rất tuyệt vời cho sự thuần khiết về mặt toán học.

const double = (n: number) => n * 2;
const plusTen = (n: number) => n + 10;

// Luồng từ phải sang trái
const compose = <T>(...fns: Array<(arg: T) => T>) => 
  (value: T) => fns.reduceRight((acc, fn) => fn(acc), value);

const processValue = compose(double, plusTen);
console.log(processValue(5)); // (5 + 10) * 2 = 30

3. Tư duy theo đường ống (Pipe)

Hầu hết các lập trình viên thích pipe hơn compose vì nó được đọc từ trái sang phải, giống như tiếng Anh. Nó rất trực quan. Bạn lấy một phần dữ liệu và chuyển nó qua một chuỗi các trạm, mỗi trạm sửa đổi nó một chút trước khi chuyển sang trạm tiếp theo.

const pipe = <T>(...fns: Array<(arg: T) => T>) => 
  (value: T) => fns.reduce((acc, fn) => fn(acc), value);

// Đường ống làm sạch chuỗi
const trim = (s: string) => s.trim();
const shout = (s: string) => s.toUpperCase();
const tag = (s: string) => `[LOG]: ${s}`;

const prepareHeader = pipe(trim, shout, tag);

console.log(prepareHeader("   chào mừng về nhà   ")); 
// "[LOG]: CHÀO MỪNG VỀ NHÀ"

Trong thực tế, tôi sử dụng pipe để xử lý các phép biến đổi dữ liệu phức tạp trong Redux reducers hoặc Express middleware. Nó giữ cho logic luôn phẳng. Bạn sẽ tránh được lỗi “Kim tự tháp diệt vong” (Pyramid of Doom), nơi bạn có tới năm dấu ngoặc đóng ở cuối một dòng duy nhất.

Kiểm thử và Khả năng quan sát

FP giúp việc kiểm thử trở nên gần như nhàm chán. Vì các hàm thuần khiết không chạm vào trạng thái toàn cục, bạn không cần các mock phức tạp hay reset beforeEach. Mỗi bài kiểm tra chỉ là một lần kiểm tra đầu vào – đầu ra đơn giản.

import { describe, it, expect } from 'vitest';

describe('Đường ống FP', () => {
  it('nên biến đổi chuỗi một cách dễ đoán', () => {
    const output = prepareHeader(" test ");
    expect(output).toBe("[LOG]: TEST");
  });
});

Gỡ lỗi đường ống

Một phàn nàn phổ biến với pipe là bạn không thể dễ dàng đặt điểm dừng (breakpoint) bên trong chuỗi. Để khắc phục điều này, tôi sử dụng một hàm hỗ trợ gọi là trace. Nó ghi log giá trị và chuyển nó đi tiếp mà không làm thay đổi giá trị đó.

const trace = <T>(label: string) => (value: T) => {
  console.log(`${label}:`, value);
  return value;
};

const debugPipeline = pipe(
  trim,
  trace("Sau khi Trim"),
  shout,
  trace("Sau khi Shout"),
  tag
);

Cách tiếp cận này biến việc gỡ lỗi thành một quá trình quan sát sự tiến hóa của dữ liệu một cách hệ thống. Bạn sẽ không còn phải săn lùng những biến đổi trạng thái “mờ ám” ẩn trong một class dài 500 dòng nữa. Thay vào đó, bạn theo dõi chính xác cách dữ liệu của mình phát triển và thay đổi ở mọi bước. Hãy bắt đầu bằng cách tái cấu trúc một tệp tiện ích nhỏ — sự rõ ràng trong buổi duyệt code tiếp theo của bạn sẽ rất đáng công sức bỏ ra.

Share: