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

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_ và _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
, array
và any
, 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ểunumber | string
. Điều này có nghĩa là TypeScript cho phép bạn gán hoặc một giá trị kiểunumber
, hoặc một giá trị kiểustring
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ểunumber | 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:
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ủaid
bằngtypeof
. Nếu làstring
, TypeScript hiểu và cho phép gọi.toUpperCase()
. Trong khốielse
(hoặcelse if
), TypeScript suy luậnid
phải lànumber
(vì chỉ còn lại kiểu đó trong Union) và cho phép gọi.toFixed()
.
- Giải thích: Bên trong khối
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ả
Bird
vàFish
đều có phương thứclayEggs
, nên việc gọipet.layEggs()
là an toàn cho bất kỳ kiểu nào trong UnionBird | Fish
. Tuy nhiên,fly
chỉ có trongBird
vàswim
chỉ có trongFish
. Chúng ta dùng'fly' in pet
để kiểm tra xem đối tượngpet
có thuộc tínhfly
hay không. Nếu có, TypeScript nới rộng kiểu củapet
thànhBird
. Ngược lại, nó nới rộng thànhFish
.
- Giải thích: Cả
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ư
typeof
vàin
,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.
- Giải thích: Tương tự như
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ữaPerson
vàEmployee
. Điều này có nghĩa là một đối tượng thuộc kiểuFullTimeEmployee
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ínhemail
(từHasEmail
) và thuộc tínhphone
(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ủavalue
trongConflictingType
sẽ trở thànhnever
. Bất kỳ đối tượng nào cố gắng thỏa mãnConflictingType
sẽ gặp lỗi biên dịch vì không thể cung cấp một giá trị chovalue
có kiểunever
.
Đâ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