Vượt xa Interface cơ bản: Xây dựng logic Type-safe với TypeScript nâng cao

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

Khi Static Types không còn đủ đáp ứng

Tôi đã chứng kiến nhiều đội ngũ tìm đến any hoặc unknown ngay khi dự án đạt ngưỡng 10.000 dòng code. Điều này thường xảy ra khi một hàm trả về các cấu trúc dữ liệu khác nhau dựa trên đầu vào, hoặc khi một đối tượng cấu hình yêu cầu các quy ước đặt tên nghiêm ngặt. Khi hệ thống kiểu (type system) không thể mô tả các mối quan hệ này, các lập trình viên thường tìm đến ép kiểu thủ công (as MyType). Thói quen này vô tình làm câm lặng trình biên dịch và bỏ qua chính các tính năng an toàn mà chúng ta kỳ vọng khi dùng TypeScript.

Các interface tĩnh sẽ thất bại khi cấu trúc dữ liệu trở nên linh hoạt. Chúng hoạt động tốt cho các thao tác CRUD cơ bản, nhưng không tiến hóa cùng với logic của bạn. Việc phụ thuộc vào chúng dẫn đến các lỗi runtime mà lẽ ra một trình biên dịch được cấu hình tốt phải bắt được. Làm chủ các kiểu dữ liệu nâng cao (advanced types) chính là cầu nối giữa việc viết các script cơ bản và việc thiết kế các thư viện mạnh mẽ, cấp doanh nghiệp giúp ngăn chặn lỗi trước khi chúng được đưa lên production.

So sánh Static Types và Logic có thể lập trình

để thấy tại sao các kiểu nâng cao lại xứng đáng với công sức bỏ ra, hãy cùng xem cách chúng ta xử lý dữ liệu động qua hai góc nhìn khác nhau.

Cách A: Mapping Interface thủ công

Mapping thủ công bao gồm việc định nghĩa mọi biến thể có thể có của một kiểu dữ liệu. Nếu một phản hồi API thay đổi dựa trên mã trạng thái, bạn có thể phải viết một union của năm interface khác nhau. Mặc dù cách này dễ đọc lúc đầu, nhưng nó trở thành nút thắt cổ chai khi mở rộng. Việc thêm một thuộc tính mới có thể yêu cầu bạn cập nhật hàng tá interface không liên quan đến nhau.

Cách B: Logic Type có thể lập trình

Bằng cách sử dụng Conditional và Mapped types, bạn tạo ra các “hàm kiểu” (type functions). Chúng tính toán kiểu dữ liệu kết quả dựa trên đầu vào được cung cấp. Khi đầu vào thay đổi, kiểu đầu ra sẽ tự động cập nhật. Bạn sẽ không cần phải chạm vào các định nghĩa interface của mình một lần nào nữa.

Tính năng Interface thủ công Logic Type nâng cao
Khả năng mở rộng Thấp (Yêu cầu cập nhật thủ công) Cao (Tự động cập nhật)
Độ an toàn kiểu Trung bình (Dễ sai sót do con người) Rất cao (Trình biên dịch thực thi)
Độ phức tạp Thấp Trung bình đến Cao

Đánh đổi khi biến đổi Type nâng cao

Các tính năng nâng cao rất mạnh mẽ, nhưng chúng không miễn phí. Bạn nên cân nhắc lợi ích so với gánh nặng nhận thức mà chúng thêm vào mã nguồn của mình.

  • Ưu điểm:
    • Loại bỏ nhu cầu sử dụng any trong các logic nghiệp vụ phức tạp.
    • Đội ngũ của bạn nhận được gợi ý Autocomplete (IntelliSense) hoàn hảo.
    • Giảm bớt mã lặp (boilerplate) bằng cách tạo ra các kiểu mới từ các kiểu đã có.
  • Nhược điểm:
    • Việc đào tạo các lập trình viên junior mất nhiều thời gian hơn do cú pháp phức tạp.
    • Thông báo lỗi của TypeScript có thể trở thành những bức tường văn bản dài 15 dòng.
    • Các monorepo khổng lồ có thể thấy thời gian biên dịch tăng 5-10% nếu lạm dụng logic quá mức.

Thiết lập môi trường

Kiểm tra phiên bản của bạn trước khi bắt đầu. Bạn cần TypeScript 4.1 trở lên để sử dụng Template Literal Types. Tệp tsconfig.json của bạn phải bật chế độ strict để đảm bảo các tính năng này hoạt động ổn định.

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "CommonJS",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  }
}

Hướng dẫn triển khai: Xây dựng hệ thống Type-Safe

Hãy cùng xem ba trụ cột của TypeScript nâng cao: Conditional, Mapped, và Template Literal Types. Chúng ta sẽ áp dụng chúng vào một bộ xử lý phản hồi API thực tế và một hệ thống sự kiện.

1. Conditional Types: Logic cho các kiểu dữ liệu

Hãy coi Conditional types như các câu lệnh “if-else” của thế giới kiểu dữ liệu. Chúng sử dụng cú pháp tương tự như toán tử ba ngôi: T extends U ? X : Y.

Giả sử bạn có một hàm lấy metadata. Nếu bạn truyền vào một string, nó sẽ trả về tên; nếu bạn truyền vào một number, nó trả về tuổi. Conditional types giúp mối quan hệ này trở nên rõ ràng.

type IdSelector<T extends string | number> = T extends string ? { name: string } : { age: number };

function getMetadata<T extends string | number>(id: T): IdSelector<T> {
    if (typeof id === "string") {
        return { name: "Tên người dùng" } as IdSelector<T>;
    }
    return { age: 30 } as IdSelector<T>;
}

const userByName = getMetadata("id_123"); // Kiểu dữ liệu là { name: string }
const userByAge = getMetadata(123);      // Kiểu dữ liệu là { age: number }

2. Mapped Types: Tự động hóa việc biến đổi

Mapped types cho phép bạn lấy một cấu trúc hiện có và biến đổi mọi thuộc tính cùng một lúc. Điều này hoàn hảo để tạo ra các phiên bản read-only của các đối tượng cấu hình hoặc thêm tiền tố vào các key mà không cần khai báo lại thủ công.

interface AppConfig {
    apiUrl: string;
    port: number;
    debugMode: boolean;
}

// Tự động tạo các phương thức getter
type GetterMethods<T> = {
    [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

const configAccessors: GetterMethods<AppConfig> = {
    getApiUrl: () => "https://api.example.com",
    getPort: () => 8080,
    getDebugMode: () => true,
};

3. Template Literal Types: An toàn với chuỗi

Template literal types cho phép bạn xây dựng các kiểu dựa trên chuỗi bằng cú pháp backtick của JavaScript. Điều này giải quyết vấn đề xác thực các chuỗi phải tuân theo một mẫu cụ thể, chẳng hạn như các class CSS hoặc tên sự kiện.

Dưới đây là cách bạn có thể xây dựng một trình lắng nghe sự kiện type-safe cho các thành phần UI.

type UIElement = "button" | "input" | "dropdown";
type EventType = "click" | "hover" | "focus";

// Tạo ra một union: "button_click" | "button_hover" | "input_click", v.v.
type DOMEvent = `${UIElement}_${EventType}`;

function registerEvent(event: DOMEvent) {
    console.log(`Đã đăng ký: ${event}`);
}

registerEvent("button_click"); // Hợp lệ
// registerEvent("sidebar_scroll"); // Lỗi: Không thể gán cho DOMEvent

Kết quả cuối cùng: Một API Wrapper thực tế

Kết hợp ba tính năng này tạo ra mã nguồn cực kỳ thông minh. Hãy tưởng tượng một API xử lý nhiều thực thể. Chúng ta muốn một hàm đảm bảo rằng chúng ta chỉ gọi các endpoint hợp lệ với đúng dữ liệu payload tương ứng.

type Entity = "User" | "Product" | "Order";
type Action = "Create" | "Delete" | "Update";

type ApiEndpoint = `/${Lowercase<Entity>}s/${Lowercase<Action>}`;

interface ApiPayload {
    "/users/create": { username: string };
    "/products/create": { productName: string; price: number };
    "/users/delete": { userId: string };
}

async function sendRequest<T extends keyof ApiPayload>(
    endpoint: T & ApiEndpoint, 
    payload: ApiPayload[T]
) {
    return fetch(endpoint, {
        method: 'POST',
        body: JSON.stringify(payload)
    });
}

// Hoạt động hoàn hảo
sendRequest("/users/create", { username: "itfromzero" });

// TypeScript cảnh báo: Thiếu thuộc tính 'price'
// sendRequest("/products/create", { productName: "Laptop" }); 

Giờ đây, trình biên dịch biết chính xác payload nào được yêu cầu dựa trên chuỗi endpoint bạn nhập. Nếu bạn thay đổi endpoint, các thuộc tính bắt buộc sẽ cập nhật ngay lập tức. Điều này giảm bớt nợ kỹ thuật bằng cách thay thế hàng trăm định nghĩa kiểu thủ công bằng một vài quy tắc tổng quát. Nó giữ cho mã nguồn của bạn sạch sẽ, dễ dự đoán và cực kỳ an toàn.

Share: