Bài 11.3: Union và Intersection types trong TypeScript

Chào mừng trở lại với chuỗi bài viết về Lập trình Web Front-end! Trong bài học hôm nay, chúng ta sẽ đi sâu vào hai khái niệm cực kỳ mạnh mẽ và linh hoạt trong hệ thống kiểu của TypeScript: _Union Types__Intersection Types_. Đây là những công cụ giúp bạn mô tả dữ liệu một cách phức tạp hơn, xử lý các tình huống dữ liệu đa dạng trong ứng dụng của mình một cách an toàn và hiệu quả.

Nếu bạn đã quen với các kiểu dữ liệu cơ bản như string, number, boolean, object, arrayany, thì Union và Intersection types sẽ nâng khả năng mô hình hóa dữ liệu của bạn lên một tầm cao mới.

Union Types: Sự kết hợp "HOẶC"

Hãy bắt đầu với Union Types. Đôi khi, một biến hoặc tham số hàm có thể nhận _một trong số_ các kiểu dữ liệu khác nhau. Ví dụ, một ID có thể là một chuỗi (UUID) hoặc một số (sequential ID). Một giá trị có thể là string hoặc null. TypeScript cung cấp Union Types để xử lý chính xác những tình huống này.

Union Type được định nghĩa bằng cách sử dụng ký hiệu dấu gạch đứng (|) giữa các kiểu dữ liệu.

Ví dụ cơ bản về Union Type

Hãy xem một ví dụ đơn giản:

let id: number | string; // Biến 'id' có thể là number HOẶC string

id = 123; // Hợp lệ
id = "abc-123"; // Hợp lệ
// id = true; // Lỗi! boolean không nằm trong union number | string
  • Giải thích: Biến id ở đây được khai báo với kiểu number | string. Điều này có nghĩa là TypeScript cho phép bạn gán hoặc một giá trị kiểu number, hoặc một giá trị kiểu string cho biến này. Bất kỳ kiểu nào khác (boolean, object, undefined, v.v.) sẽ gây ra lỗi biên dịch.
Union Types trong tham số hàm

Một trường hợp sử dụng rất phổ biến của Union Types là trong khai báo tham số của hàm, khi hàm có thể xử lý nhiều loại đầu vào khác nhau.

function printId(id: number | string) {
  console.log(`ID của bạn là: ${id}`);
}

printId(101); // In ra: ID của bạn là: 101
printId("202-abc"); // In ra: ID của bạn là: 202-abc
// printId({ myId: 220 }); // Lỗi! Đối số không phải number hoặc string
  • Giải thích: Hàm printId nhận một tham số id có kiểu number | string. Hàm này hoạt động đúng với cả hai kiểu dữ liệu đã khai báo.
Nới rộng kiểu (Narrowing) với Union Types

Khi bạn làm việc với một biến có Union Type, TypeScript chỉ cho phép bạn thực hiện các thao tác _hợp lệ cho TẤT CẢ_ các kiểu trong Union đó. Ví dụ, bạn không thể trực tiếp gọi phương thức .toUpperCase() trên một biến kiểu number | string vì kiểu number không có phương thức này.

Để thực hiện các thao tác đặc thù cho một kiểu cụ thể trong Union, bạn cần phải "nới rộng" (narrow) kiểu đó. Quá trình nới rộng là việc sử dụng các kiểm tra runtime (như typeof, instanceof, kiểm tra thuộc tính) để TypeScript có thể suy luận chính xác kiểu tại một thời điểm cụ thể trong code của bạn.

Các cách nới rộng phổ biến:

  1. Sử dụng typeof: Thường dùng cho các kiểu nguyên thủy (string, number, boolean, symbol, bigint, undefined).

    function printFormattedId(id: number | string) {
      if (typeof id === 'string') {
        // Tại đây, TypeScript biết 'id' chắc chắn là string
        console.log(id.toUpperCase()); // Hợp lệ
      } else {
        // Tại đây, TypeScript biết 'id' chắc chắn là number
        console.log(id.toFixed(2)); // Hợp lệ
      }
    }
    
    printFormattedId("abc"); // In ra: ABC
    printFormattedId(123.456); // In ra: 123.46
    
    • Giải thích: Bên trong khối if, chúng ta kiểm tra kiểu của id bằng typeof. Nếu là string, TypeScript hiểu và cho phép gọi .toUpperCase(). Trong khối else (hoặc else if), TypeScript suy luận id phải là number (vì chỉ còn lại kiểu đó trong Union) và cho phép gọi .toFixed().
  2. Sử dụng in: Dùng để kiểm tra sự tồn tại của một thuộc tính trong đối tượng. Rất hữu ích khi Union bao gồm các kiểu đối tượng có cấu trúc khác nhau.

    interface Bird {
      fly(): void;
      layEggs(): void;
    }
    
    interface Fish {
      swim(): void;
      layEggs(): void;
    }
    
    type Pet = Bird | Fish; // Pet có thể là Bird HOẶC Fish
    
    function move(pet: Pet) {
      if ('fly' in pet) {
        // Tại đây, TypeScript biết 'pet' chắc chắn là Bird
        pet.fly();
      } else {
        // Tại đây, TypeScript biết 'pet' chắc chắn là Fish
        pet.swim();
      }
    }
    
    // layEggs() hợp lệ cho cả Bird và Fish, nên không cần kiểm tra
    function commonAction(pet: Pet) {
        pet.layEggs(); // Hợp lệ mà không cần 'in' hoặc 'typeof'
    }
    
    • Giải thích: Cả BirdFish đều có phương thức layEggs, nên việc gọi pet.layEggs() là an toàn cho bất kỳ kiểu nào trong Union Bird | Fish. Tuy nhiên, fly chỉ có trong Birdswim chỉ có trong Fish. Chúng ta dùng 'fly' in pet để kiểm tra xem đối tượng pet có thuộc tính fly hay không. Nếu có, TypeScript nới rộng kiểu của pet thành Bird. Ngược lại, nó nới rộng thành Fish.
  3. Sử dụng instanceof: Dùng để kiểm tra xem một đối tượng có phải là thể hiện của một lớp cụ thể hay không.

    class Dog {
      bark() { console.log('Woof!'); }
    }
    
    class Cat {
      meow() { console.log('Meow!'); }
    }
    
    type Animal = Dog | Cat; // Animal có thể là Dog HOẶC Cat
    
    function play(animal: Animal) {
      if (animal instanceof Dog) {
        // Tại đây, TypeScript biết 'animal' chắc chắn là Dog
        animal.bark();
      } else {
        // Tại đây, TypeScript biết 'animal' chắc chắn là Cat
        animal.meow();
      }
    }
    
    • Giải thích: Tương tự như typeofin, instanceof giúp chúng ta phân biệt các kiểu trong Union khi chúng là các thể hiện của lớp.

Union Types mang lại sự linh hoạt tuyệt vời khi bạn cần xử lý dữ liệu có thể có nhiều dạng khác nhau, đồng thời vẫn giữ được tính an toàn của kiểu nhờ khả năng nới rộng.

Intersection Types: Sự kết hợp "VÀ"

Trái ngược với Union Types (HOẶC), Intersection Types (VÀ) cho phép bạn kết hợp _nhiều kiểu thành một kiểu duy nhất_. Kiểu mới này sẽ có _tất cả các thành viên_ (thuộc tính và phương thức) của các kiểu ban đầu. Intersection Type được định nghĩa bằng cách sử dụng ký hiệu dấu và (&) giữa các kiểu dữ liệu.

Intersection Types thường được sử dụng để "mix in" các thuộc tính, tạo ra các kiểu phức tạp hơn bằng cách kết hợp các kiểu đơn giản hơn. Nó rất hữu ích khi bạn muốn một đối tượng vừa có các thuộc tính của kiểu A, vừa có các thuộc tính của kiểu B, vừa có các thuộc tính của kiểu C, v.v.

Ví dụ cơ bản về Intersection Type

Hãy xem một ví dụ kết hợp hai interface đơn giản:

interface Person {
  name: string;
  age: number;
}

interface Employee {
  employeeId: string;
  department: string;
}

// FullTimeEmployee phải có TẤT CẢ thuộc tính của Person VÀ TẤT CẢ thuộc tính của Employee
type FullTimeEmployee = Person & Employee;

const juniorDev: FullTimeEmployee = {
  name: "Alice",
  age: 25,
  employeeId: "DEV-1001",
  department: "IT",
  // Không thể thiếu bất kỳ thuộc tính nào từ Person hoặc Employee
};

// const partTimeDev: FullTimeEmployee = { // Lỗi! Thiếu employeeId và department
//   name: "Bob",
//   age: 30,
// };
  • Giải thích: Kiểu FullTimeEmployee là sự giao thoa (&) giữa PersonEmployee. Điều này có nghĩa là một đối tượng thuộc kiểu FullTimeEmployee bắt buộc phải có cả ba thuộc tính: name, age (từ Person) và employeeId, department (từ Employee). Nếu thiếu bất kỳ thuộc tính nào trong số này, TypeScript sẽ báo lỗi.
Kết hợp nhiều kiểu

Bạn có thể kết hợp nhiều hơn hai kiểu bằng Intersection Types:

interface HasEmail {
    email: string;
}

interface HasPhone {
    phone: string;
}

// ContactInfo có cả email VÀ phone
type ContactInfo = HasEmail & HasPhone;

const userContact: ContactInfo = {
    email: "user@example.com",
    phone: "123-456-7890"
};
  • Giải thích: ContactInfo yêu cầu đối tượng phải có cả thuộc tính email (từ HasEmail) và thuộc tính phone (từ HasPhone).
Xung đột thuộc tính trong Intersection Types

Điều gì xảy ra nếu các kiểu bạn kết hợp có cùng tên thuộc tính nhưng khác kiểu dữ liệu?

interface ConflictA {
  value: string;
}

interface ConflictB {
  value: number;
}

// Type C must have a 'value' that is BOTH string AND number?
type ConflictingType = ConflictA & ConflictB;

// const example: ConflictingType = { // Lỗi!
//     value: "hello" // string không phải là number
//     value: 123     // number không phải là string
//     value: ???     // Không có giá trị nào vừa là string vừa là number
// };
  • Giải thích: TypeScript cố gắng tạo ra một kiểu mà tất cả các điều kiện đều đúng. Đối với thuộc tính value, nó cần một giá trị vừa là string lại vừa là number. Điều này là không thể trong JavaScript/TypeScript. Kết quả là, kiểu của value trong ConflictingType sẽ trở thành never. Bất kỳ đối tượng nào cố gắng thỏa mãn ConflictingType sẽ gặp lỗi biên dịch vì không thể cung cấp một giá trị cho value có kiểu never.

Đây là một điểm quan trọng cho thấy Intersection Types đại diện cho logic "VÀ" một cách nghiêm ngặt.

Union vs. Intersection: Khác biệt cốt lõi

Để tóm lại sự khác biệt quan trọng nhất giữa hai loại này:

  • Union Types (|): Đại diện cho logic _HOẶC_. Một giá trị có thể là một trong số các kiểu được liệt kê. Nó giúp làm việc với dữ liệu có thể có nhiều hình dạng khác nhau.
  • Intersection Types (&): Đại diện cho logic _VÀ_. Một giá trị phải có tất cả các đặc điểm (thuộc tính, phương thức) của các kiểu được liệt kê. Nó giúp kết hợp các đặc điểm từ nhiều nguồn để tạo ra một kiểu mới, phức tạp hơn.

Hãy hình dung trên sơ đồ Venn:

  • Union (A | B): Vùng tô màu bao gồm cả vùng A và vùng B.
  • Intersection (A & B): Vùng tô màu chỉ là phần giao nhau giữa A và B.

Comments

There are no comments at the moment.