Tiếng Việt

Khám phá kiểu branded trong TypeScript, một kỹ thuật mạnh mẽ để đạt được kiểu danh nghĩa trong hệ thống kiểu cấu trúc. Tìm hiểu cách tăng cường an toàn kiểu và sự rõ ràng của mã.

Kiểu Branded trong TypeScript: Định kiểu Danh nghĩa trong Hệ thống Cấu trúc

Hệ thống kiểu cấu trúc của TypeScript mang lại sự linh hoạt nhưng đôi khi có thể dẫn đến hành vi không mong muốn. Kiểu branded (kiểu được "đóng dấu") cung cấp một cách để thực thi định kiểu danh nghĩa, tăng cường an toàn kiểu và sự rõ ràng của mã. Bài viết này khám phá chi tiết về kiểu branded, cung cấp các ví dụ thực tế và các phương pháp hay nhất để triển khai chúng.

Hiểu về Định kiểu Cấu trúc và Định kiểu Danh nghĩa

Trước khi đi sâu vào kiểu branded, hãy làm rõ sự khác biệt giữa định kiểu cấu trúc và định kiểu danh nghĩa.

Định kiểu Cấu trúc (Duck Typing)

Trong một hệ thống kiểu cấu trúc, hai kiểu được coi là tương thích nếu chúng có cùng cấu trúc (tức là cùng thuộc tính với cùng kiểu dữ liệu). TypeScript sử dụng định kiểu cấu trúc. Hãy xem xét ví dụ này:


interface Point {
  x: number;
  y: number;
}

interface Vector {
  x: number;
  y: number;
}

const point: Point = { x: 10, y: 20 };
const vector: Vector = point; // Hợp lệ trong TypeScript

console.log(vector.x); // Output: 10

Mặc dù PointVector được khai báo là các kiểu riêng biệt, TypeScript vẫn cho phép gán một đối tượng Point cho một biến Vector vì chúng có cùng cấu trúc. Điều này có thể tiện lợi, nhưng cũng có thể dẫn đến lỗi nếu bạn cần phân biệt giữa các kiểu khác nhau về mặt logic nhưng tình cờ có cùng hình dạng. Ví dụ, hãy nghĩ đến tọa độ vĩ độ/kinh độ có thể vô tình khớp với tọa độ pixel trên màn hình.

Định kiểu Danh nghĩa

Trong một hệ thống kiểu danh nghĩa, các kiểu chỉ được coi là tương thích nếu chúng có cùng tên. Ngay cả khi hai kiểu có cùng cấu trúc, chúng vẫn được coi là khác biệt nếu có tên khác nhau. Các ngôn ngữ như Java và C# sử dụng định kiểu danh nghĩa.

Sự cần thiết của Kiểu Branded

Định kiểu cấu trúc của TypeScript có thể gây ra vấn đề khi bạn cần đảm bảo rằng một giá trị thuộc về một kiểu cụ thể, bất kể cấu trúc của nó. Ví dụ, hãy xem xét việc biểu diễn các loại tiền tệ. Bạn có thể có các kiểu khác nhau cho USD và EUR, nhưng cả hai đều có thể được biểu diễn dưới dạng số. Nếu không có cơ chế để phân biệt chúng, bạn có thể vô tình thực hiện các phép toán trên sai loại tiền tệ.

Kiểu branded giải quyết vấn đề này bằng cách cho phép bạn tạo ra các kiểu riêng biệt có cấu trúc tương tự nhưng được hệ thống kiểu coi là khác nhau. Điều này tăng cường an toàn kiểu và ngăn ngừa các lỗi có thể bị bỏ sót.

Triển khai Kiểu Branded trong TypeScript

Kiểu branded được triển khai bằng cách sử dụng kiểu giao (intersection types) và một symbol hoặc chuỗi ký tự duy nhất. Ý tưởng là thêm một "dấu hiệu" (brand) vào một kiểu để phân biệt nó với các kiểu khác có cùng cấu trúc.

Sử dụng Symbols (Đề xuất)

Sử dụng symbols để "đóng dấu" thường được ưu tiên hơn vì symbols được đảm bảo là duy nhất.


const USD = Symbol('USD');
type USD = number & { readonly [USD]: unique symbol };

const EUR = Symbol('EUR');
type EUR = number & { readonly [EUR]: unique symbol };

function createUSD(value: number): USD {
  return value as USD;
}

function createEUR(value: number): EUR {
  return value as EUR;
}

function addUSD(a: USD, b: USD): USD {
  return (a + b) as USD;
}

const usd1 = createUSD(10);
const usd2 = createUSD(20);
const eur1 = createEUR(15);

const totalUSD = addUSD(usd1, usd2);
console.log("Total USD:", totalUSD);

// Bỏ chú thích dòng tiếp theo sẽ gây ra lỗi kiểu
// const invalidOperation = addUSD(usd1, eur1);

Trong ví dụ này, USDEUR là các kiểu branded dựa trên kiểu number. unique symbol đảm bảo rằng các kiểu này là riêng biệt. Các hàm createUSDcreateEUR được sử dụng để tạo ra các giá trị của các kiểu này, và hàm addUSD chỉ chấp nhận các giá trị USD. Việc cố gắng cộng một giá trị EUR vào một giá trị USD sẽ gây ra lỗi kiểu.

Sử dụng Chuỗi ký tự (String Literals)

Bạn cũng có thể sử dụng chuỗi ký tự để "đóng dấu", mặc dù cách tiếp cận này kém vững chắc hơn so với việc sử dụng symbols vì chuỗi ký tự không được đảm bảo là duy nhất.


type USD = number & { readonly __brand: 'USD' };
type EUR = number & { readonly __brand: 'EUR' };

function createUSD(value: number): USD {
  return value as USD;
}

function createEUR(value: number): EUR {
  return value as EUR;
}

function addUSD(a: USD, b: USD): USD {
  return (a + b) as USD;
}

const usd1 = createUSD(10);
const usd2 = createUSD(20);
const eur1 = createEUR(15);

const totalUSD = addUSD(usd1, usd2);
console.log("Total USD:", totalUSD);

// Bỏ chú thích dòng tiếp theo sẽ gây ra lỗi kiểu
// const invalidOperation = addUSD(usd1, eur1);

Ví dụ này đạt được kết quả tương tự như ví dụ trước, nhưng sử dụng chuỗi ký tự thay vì symbols. Mặc dù đơn giản hơn, điều quan trọng là phải đảm bảo rằng các chuỗi ký tự được sử dụng để "đóng dấu" là duy nhất trong codebase của bạn.

Ví dụ và Trường hợp sử dụng Thực tế

Kiểu branded có thể được áp dụng cho nhiều kịch bản khác nhau nơi bạn cần thực thi an toàn kiểu vượt ra ngoài khả năng tương thích về cấu trúc.

Các loại ID

Hãy xem xét một hệ thống có các loại ID khác nhau, chẳng hạn như UserID, ProductID, và OrderID. Tất cả các ID này có thể được biểu diễn dưới dạng số hoặc chuỗi, nhưng bạn muốn ngăn chặn việc vô tình trộn lẫn các loại ID khác nhau.


const UserIDBrand = Symbol('UserID');
type UserID = string & { readonly [UserIDBrand]: unique symbol };

const ProductIDBrand = Symbol('ProductID');
type ProductID = string & { readonly [ProductIDBrand]: unique symbol };

function getUser(id: UserID): { name: string } {
  // ... lấy dữ liệu người dùng
  return { name: "Alice" };
}

function getProduct(id: ProductID): { name: string, price: number } {
  // ... lấy dữ liệu sản phẩm
  return { name: "Example Product", price: 25 };
}

function createUserID(id: string): UserID {
  return id as UserID;
}

function createProductID(id: string): ProductID {
  return id as ProductID;
}

const userID = createUserID('user123');
const productID = createProductID('product456');

const user = getUser(userID);
const product = getProduct(productID);

console.log("User:", user);
console.log("Product:", product);

// Bỏ chú thích dòng tiếp theo sẽ gây ra lỗi kiểu
// const invalidCall = getUser(productID);

Ví dụ này minh họa cách các kiểu branded có thể ngăn chặn việc truyền một ProductID vào một hàm mong đợi một UserID, giúp tăng cường an toàn kiểu.

Các giá trị theo Miền nghiệp vụ (Domain-Specific)

Kiểu branded cũng có thể hữu ích để biểu diễn các giá trị theo miền nghiệp vụ với các ràng buộc. Ví dụ, bạn có thể có một kiểu cho tỷ lệ phần trăm luôn phải nằm trong khoảng từ 0 đến 100.


const PercentageBrand = Symbol('Percentage');
type Percentage = number & { readonly [PercentageBrand]: unique symbol };

function createPercentage(value: number): Percentage {
  if (value < 0 || value > 100) {
    throw new Error('Percentage must be between 0 and 100');
  }
  return value as Percentage;
}

function applyDiscount(price: number, discount: Percentage): number {
  return price * (1 - discount / 100);
}

try {
  const discount = createPercentage(20);
  const discountedPrice = applyDiscount(100, discount);
  console.log("Discounted Price:", discountedPrice);

  // Bỏ chú thích dòng tiếp theo sẽ gây ra lỗi trong thời gian chạy
  // const invalidPercentage = createPercentage(120);
} catch (error) {
  console.error(error);
}

Ví dụ này cho thấy cách thực thi một ràng buộc trên giá trị của một kiểu branded trong thời gian chạy (runtime). Mặc dù hệ thống kiểu không thể đảm bảo rằng một giá trị Percentage luôn nằm trong khoảng từ 0 đến 100, hàm createPercentage có thể thực thi ràng buộc này tại thời điểm chạy. Bạn cũng có thể sử dụng các thư viện như io-ts để thực thi xác thực thời gian chạy cho các kiểu branded.

Biểu diễn Ngày và Giờ

Làm việc với ngày và giờ có thể phức tạp do có nhiều định dạng và múi giờ khác nhau. Kiểu branded có thể giúp phân biệt giữa các cách biểu diễn ngày và giờ khác nhau.


const UTCDateBrand = Symbol('UTCDate');
type UTCDate = string & { readonly [UTCDateBrand]: unique symbol };

const LocalDateBrand = Symbol('LocalDate');
type LocalDate = string & { readonly [LocalDateBrand]: unique symbol };

function createUTCDate(dateString: string): UTCDate {
  // Xác thực rằng chuỗi ngày ở định dạng UTC (ví dụ: ISO 8601 với Z)
  if (!/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/.test(dateString)) {
    throw new Error('Invalid UTC date format');
  }
  return dateString as UTCDate;
}

function createLocalDate(dateString: string): LocalDate {
  // Xác thực rằng chuỗi ngày ở định dạng ngày địa phương (ví dụ: YYYY-MM-DD)
  if (!/\d{4}-\d{2}-\d{2}/.test(dateString)) {
    throw new Error('Invalid local date format');
  }
  return dateString as LocalDate;
}

function convertUTCDateToLocalDate(utcDate: UTCDate): LocalDate {
  // Thực hiện chuyển đổi múi giờ
  const date = new Date(utcDate);
  const localDateString = date.toLocaleDateString();
  return createLocalDate(localDateString);
}

try {
  const utcDate = createUTCDate('2024-01-20T10:00:00.000Z');
  const localDate = convertUTCDateToLocalDate(utcDate);
  console.log("UTC Date:", utcDate);
  console.log("Local Date:", localDate);
} catch (error) {
  console.error(error);
}

Ví dụ này phân biệt giữa ngày UTC và ngày địa phương, đảm bảo rằng bạn đang làm việc với đúng cách biểu diễn ngày và giờ trong các phần khác nhau của ứng dụng. Việc xác thực tại thời gian chạy đảm bảo rằng chỉ những chuỗi ngày có định dạng đúng mới có thể được gán cho các kiểu này.

Các Phương pháp Tốt nhất khi Sử dụng Kiểu Branded

Để sử dụng hiệu quả các kiểu branded trong TypeScript, hãy xem xét các phương pháp tốt nhất sau đây:

Ưu điểm của Kiểu Branded

Nhược điểm của Kiểu Branded

Các giải pháp thay thế cho Kiểu Branded

Mặc dù kiểu branded là một kỹ thuật mạnh mẽ để đạt được định kiểu danh nghĩa trong TypeScript, có những cách tiếp cận thay thế mà bạn có thể xem xét.

Kiểu Opaque (Opaque Types)

Kiểu opaque tương tự như kiểu branded nhưng cung cấp một cách rõ ràng hơn để ẩn đi kiểu cơ sở. TypeScript không có hỗ trợ tích hợp cho kiểu opaque, nhưng bạn có thể mô phỏng chúng bằng cách sử dụng module và các symbol riêng tư.

Lớp (Classes)

Sử dụng các lớp có thể cung cấp một cách tiếp cận hướng đối tượng hơn để định nghĩa các kiểu riêng biệt. Mặc dù các lớp trong TypeScript được định kiểu theo cấu trúc, chúng mang lại sự phân tách rõ ràng hơn về các mối quan tâm và có thể được sử dụng để thực thi các ràng buộc thông qua các phương thức.

Các thư viện như `io-ts` hoặc `zod`

Các thư viện này cung cấp khả năng xác thực kiểu tại thời gian chạy một cách tinh vi và có thể được kết hợp với các kiểu branded để đảm bảo an toàn cả ở thời điểm biên dịch và thời gian chạy.

Kết luận

Kiểu branded trong TypeScript là một công cụ có giá trị để tăng cường an toàn kiểu và sự rõ ràng của mã trong một hệ thống kiểu cấu trúc. Bằng cách thêm một "dấu hiệu" vào một kiểu, bạn có thể thực thi định kiểu danh nghĩa và ngăn chặn việc vô tình trộn lẫn các kiểu có cấu trúc tương tự nhưng khác nhau về mặt logic. Mặc dù kiểu branded gây ra một số phức tạp và chi phí, lợi ích từ việc cải thiện an toàn kiểu và khả năng bảo trì mã thường lớn hơn những nhược điểm. Hãy xem xét sử dụng kiểu branded trong các kịch bản mà bạn cần đảm bảo một giá trị thuộc về một kiểu cụ thể, bất kể cấu trúc của nó.

Bằng cách hiểu các nguyên tắc đằng sau định kiểu cấu trúc và danh nghĩa, và bằng cách áp dụng các phương pháp tốt nhất được nêu trong bài viết này, bạn có thể tận dụng hiệu quả các kiểu branded để viết mã TypeScript mạnh mẽ và dễ bảo trì hơn. Từ việc biểu diễn tiền tệ và ID đến việc thực thi các ràng buộc theo miền nghiệp vụ, kiểu branded cung cấp một cơ chế linh hoạt và mạnh mẽ để tăng cường an toàn kiểu trong các dự án của bạn.

Khi làm việc với TypeScript, hãy khám phá các kỹ thuật và thư viện khác nhau có sẵn để xác thực và thực thi kiểu. Hãy xem xét việc sử dụng kiểu branded kết hợp với các thư viện xác thực thời gian chạy như io-ts hoặc zod để đạt được một cách tiếp cận toàn diện về an toàn kiểu.