Union, Intersection và Narrowing trong TypeScript
1. Tổng quan
Union và intersection là hai công cụ cơ bản để TypeScript mô tả dữ liệu linh hoạt hơn interface đơn thuần. Narrowing là kỹ thuật giúp compiler “hiểu” được kiểu cụ thể của một giá trị tại từng nhánh code, thay vì bắt bạn ép kiểu (as) thủ công.
Bài này đi từ khái niệm union/intersection, qua các kỹ thuật narrowing phổ biến (typeof, instanceof, in, discriminated union), đến cách viết type guard tùy chỉnh. Đọc xong, bạn sẽ biết khi nào dùng union, khi nào dùng intersection, và tránh được lỗi Object is possibly undefined hay as any tràn lan trong code.
2. Union type
Union (|) nghĩa là một giá trị có thể thuộc một trong nhiều kiểu.
type PaymentMethod = "cash" | "card" | "e-wallet";
function describe(method: PaymentMethod): string {
return `Thanh toán bằng: ${method}`;
}
describe("card"); // OK
// describe("paypal"); // Lỗi compile: không nằm trong union
PaymentMethod chỉ nhận 3 chuỗi literal cụ thể. Nếu truyền giá trị khác, TypeScript báo lỗi ngay lúc compile, không phải chờ đến runtime.
Union cũng áp dụng được với object type:
interface CashPayment {
type: "cash";
amount: number;
}
interface CardPayment {
type: "card";
amount: number;
cardNumber: string;
}
type Payment = CashPayment | CardPayment;
Lúc này, biến kiểu Payment có thể là CashPayment hoặc CardPayment, nhưng TypeScript chỉ cho bạn truy cập các field chung của cả hai (ở đây là type và amount) nếu chưa narrowing.
function printAmount(p: Payment) {
console.log(p.amount); // OK, field chung
// console.log(p.cardNumber); // Lỗi: không tồn tại trên CashPayment
}
Lưu ý: Union không gộp các field lại với nhau. Muốn truy cập field riêng, bạn phải narrowing trước (xem mục 4).
3. Intersection type
Intersection (&) nghĩa là một giá trị phải thỏa mãn đồng thời tất cả các kiểu được kết hợp.
interface Identifiable {
id: string;
}
interface Timestamped {
createdAt: Date;
}
type Order = Identifiable & Timestamped & {
customerName: string;
total: number;
};
const order: Order = {
id: "ORD-001",
createdAt: new Date(),
customerName: "Nguyễn Văn A",
total: 150_000,
};
Order ở đây có đầy đủ field của Identifiable, Timestamped và object literal thêm vào. Thiếu bất kỳ field nào, compiler sẽ báo lỗi.
Intersection hữu ích khi bạn muốn “trộn” nhiều mixin nhỏ thành một type lớn, tương tự composition trong OOP nhưng ở cấp độ type.
type AuditInfo = { updatedBy: string };
type AuditedOrder = Order & AuditInfo;
const audited: AuditedOrder = {
...order,
updatedBy: "admin",
};
3.1. Intersection với kiểu nguyên thủy mâu thuẫn
Nếu hai kiểu trong intersection xung đột về primitive, kết quả là never:
type Impossible = string & number; // kiểu never, không có giá trị nào thỏa mãn
Trường hợp này thường là dấu hiệu bạn gõ nhầm & thay vì |.
4. Narrowing: thu hẹp kiểu trong từng nhánh code
Narrowing là quá trình compiler tự suy luận kiểu cụ thể hơn dựa vào điều kiện kiểm tra trong code (if, switch, typeof…).
4.1. Narrowing với typeof
Dùng cho union gồm các kiểu nguyên thủy.
function formatId(id: string | number): string {
if (typeof id === "number") {
return id.toFixed(0); // id được narrow về number
}
return id.toUpperCase(); // id được narrow về string
}
Trong nhánh if, TypeScript biết id là number nên cho gọi .toFixed(). Ở nhánh else (ngầm định), id chỉ còn là string.
4.2. Narrowing với instanceof
Dùng cho union gồm class.
class ValidationError extends Error {
constructor(public field: string) {
super(`Field ${field} is invalid`);
}
}
function handleError(err: Error | ValidationError): void {
if (err instanceof ValidationError) {
console.log(`Lỗi validate field: ${err.field}`);
} else {
console.log(`Lỗi khác: ${err.message}`);
}
}
err.field chỉ tồn tại trên ValidationError, nên compiler chỉ cho truy cập trong nhánh đã kiểm tra instanceof.
4.3. Narrowing với in
Dùng khi các kiểu trong union phân biệt bằng field riêng (không có field discriminant chung).
interface Dog {
bark: () => void;
}
interface Cat {
meow: () => void;
}
function makeSound(pet: Dog | Cat): void {
if ("bark" in pet) {
pet.bark(); // narrow về Dog
} else {
pet.meow(); // narrow về Cat
}
}
Toán tử in kiểm tra property có tồn tại trên object tại runtime, và compiler dùng kết quả đó để narrow type tương ứng.
4.4. Discriminated union — cách an toàn nhất
Đây là pattern phổ biến nhất trong code thực tế: thêm một field literal chung (thường gọi là kind hoặc type) để phân biệt các “nhánh” trong union.
interface SuccessResult {
status: "success";
data: string;
}
interface ErrorResult {
status: "error";
message: string;
}
type ApiResult = SuccessResult | ErrorResult;
function handleResult(result: ApiResult): void {
switch (result.status) {
case "success":
console.log(`Data: ${result.data}`);
break;
case "error":
console.log(`Error: ${result.message}`);
break;
}
}
Compiler dùng field status làm “discriminant”: trong case "success", result được narrow về SuccessResult; trong case "error", narrow về ErrorResult. Đây là cách TypeScript khuyến nghị cho mọi union phức tạp hơn 2 nhánh, vì switch còn hỗ trợ kiểm tra exhaustiveness (mục 4.6).
4.5. Narrowing với truthiness và optional chaining
interface Customer {
name: string;
phone?: string;
}
function callCustomer(customer: Customer): void {
if (customer.phone) {
// customer.phone được narrow từ string | undefined thành string
console.log(`Gọi số: ${customer.phone}`);
} else {
console.log("Khách hàng chưa có số điện thoại");
}
}
Kiểm tra truthy (if (customer.phone)) loại bỏ undefined (và chuỗi rỗng, nhưng với số điện thoại thì không quan trọng).
4.6. Exhaustiveness check với never
Khi thêm nhánh mới vào union mà quên xử lý, bạn muốn compiler báo lỗi ngay thay vì bug âm thầm lúc runtime. Dùng biến kiểu never ở nhánh default:
function assertUnreachable(x: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(x)}`);
}
function handleResultSafe(result: ApiResult): string {
switch (result.status) {
case "success":
return result.data;
case "error":
return result.message;
default:
return assertUnreachable(result); // nếu thêm nhánh mới mà quên xử lý, lỗi compile tại đây
}
}
Nếu sau này ai đó thêm PendingResult vào ApiResult mà quên thêm case tương ứng, result ở nhánh default sẽ không còn là never, và compiler báo lỗi ngay khi build — không cần chờ bug xuất hiện ở production.
5. User-defined type guard
Khi logic kiểm tra phức tạp hơn một biểu thức đơn giản, bạn có thể viết hàm trả về kiểu x is T để tái sử dụng narrowing.
interface PaidOrder {
status: "paid";
paidAt: Date;
}
interface PendingOrder {
status: "pending";
}
type OrderState = PaidOrder | PendingOrder;
function isPaid(order: OrderState): order is PaidOrder {
return order.status === "paid";
}
function printReceipt(order: OrderState): void {
if (isPaid(order)) {
console.log(`Đã thanh toán lúc: ${order.paidAt.toISOString()}`);
} else {
console.log("Chưa thanh toán");
}
}
order is PaidOrder là một type predicate. Compiler tin tưởng kết quả hàm isPaid để narrow order thành PaidOrder trong nhánh if, kể cả khi logic kiểm tra nằm ở hàm riêng biệt.
6. Kiểm chứng bằng test
Narrowing là tính năng compile-time nên “test” thực sự là chạy tsc không báo lỗi. Nhưng bạn vẫn nên viết test cho hành vi runtime tương ứng.
npm install -D vitest typescript
import { describe, it, expect } from "vitest";
function totalAmount(payment: CashPayment | CardPayment): number {
return payment.amount;
}
describe("Payment narrowing", () => {
it("tính đúng amount cho cash payment", () => {
const cash: CashPayment = { type: "cash", amount: 50_000 };
expect(totalAmount(cash)).toBe(50_000);
});
it("tính đúng amount cho card payment", () => {
const card: CardPayment = {
type: "card",
amount: 200_000,
cardNumber: "4111111111111111",
};
expect(totalAmount(card)).toBe(200_000);
});
});
Chạy npx vitest run cho kết quả:
✓ Payment narrowing > tính đúng amount cho cash payment
✓ Payment narrowing > tính đúng amount cho card payment
Test Files 1 passed (1)
Tests 2 passed (2)
7. Lỗi hay gặp và khi nào không nên dùng
- Lạm dụng
asđể né narrowing: nếu bạn thấy mình ép kiểu (as SomeType) liên tục để “chiều” compiler, nhiều khả năng union/type guard đang thiết kế sai. Hãy quay lại dùng discriminated union. - Intersection hai interface có field cùng tên khác kiểu: TypeScript sẽ cố gắng hợp nhất, nhưng nếu kiểu xung đột (ví dụ một bên
string, một bênnumber), field đó trở thànhnever— rất dễ gây lỗi khó hiểu. Luôn đặt tên field thống nhất khi thiết kế mixin. - Union quá rộng (ví dụ
string | number | boolean | object): khiến mọi nơi dùng type đều phải narrowing, code rối. Nếu union có quá 4–5 nhánh không liên quan, xem lại domain model — có thể nên tách thành nhiều hàm/overload riêng. - Quên
defaultvớinevertrongswitch: mất luôn lợi ích exhaustiveness check, dễ bỏ sót nhánh khi mở rộng union. inoperator trên object có prototype chain phức tạp:inkiểm tra cả property kế thừa, có thể cho kết quả narrowing không như mong đợi với class có inheritance sâu. Khi đó,instanceofhoặc discriminant field đáng tin cậy hơn.
Lưu ý: Narrowing chỉ hoạt động tốt khi TypeScript có thể chứng minh bằng control flow tĩnh. Nếu bạn gán giá trị đã narrow vào biến khác qua một hàm callback bất đồng bộ (
setTimeout, promise.then), kiểu narrow có thể bị “quên” lại — TypeScript không track qua ranh giới closure một cách tuyệt đối trong mọi trường hợp.
8. Kết luận
Union mô tả “một trong nhiều kiểu”, intersection mô tả “tất cả các kiểu cùng lúc” — hai công cụ này là nền tảng để xây domain model chính xác trong TypeScript. Narrowing (typeof, instanceof, in, discriminated union, type guard tùy chỉnh) giúp bạn khai thác union một cách an toàn mà không cần ép kiểu thủ công.
Quy tắc thực dụng: ưu tiên discriminated union khi có từ 2 nhánh trở lên, luôn thêm exhaustiveness check bằng never cho switch, và tránh intersection giữa các type có field trùng tên nhưng kiểu khác nhau. Áp dụng đúng, bạn sẽ bắt được rất nhiều lỗi logic ngay lúc viết code, trước khi nó kịp chạy ra production.