← TypeScript Thực Chiến

Bài 5 · Vận dụng · 22 phút

Union & narrowing

Biên soạn bởi Nguyễn Anh Tuấn

Union type (A | B) và thu hẹp kiểu (narrowing) bằng typeof / instanceof / in / kiểm tra truthiness; discriminated (tagged) union để mô hình hoá trạng thái; kiểm tra vét cạn (exhaustiveness) bằng never. Cách mô hình hoá dữ liệu an toàn và để compiler nhắc khi thiếu nhánh.

Dữ liệu thật hay có dạng "một trong vài khả năng": một id có thể là chuỗi hoặc số, một ô có thể có giá trị hoặc rỗng. TypeScript mô tả điều đó bằng union type với dấu |: string | number nghĩa là "string HOẶC number".

Nhưng có một luật: khi biến còn là union, TS chỉ cho dùng những gì chung cho mọi nhánh. Gọi method riêng của một nhánh là lỗi ngay:

union chưa thu hẹp → lỗi

function inHoa(id: string | number) {
  return id.toUpperCase();   // number lam gi co toUpperCase
}

tsc báo

error TS2339: Property 'toUpperCase' does not exist on type 'string | number'.
  Property 'toUpperCase' does not exist on type 'number'.
  • Union A | B mô tả "giá trị thuộc một trong các kiểu liệt kê".
  • Khi còn là union, chỉ dùng được thành viên CHUNG của mọi nhánh.
  • Muốn dùng phần riêng của một nhánh, phải thu hẹp kiểu (narrowing) trước.

Narrowing là cho TypeScript bằng chứng để nó biết chắc đang ở nhánh nào. Các công cụ quen thuộc của JavaScript: typeof (cho nguyên thuỷ), instanceof (cho class), in (kiểm có thuộc tính), và kiểm truthiness/bằng nhau. TS theo dõi luồng điều kiện và tự hẹp kiểu trong mỗi nhánh:

narrow bằng typeof

function inRa(id: string | number): string {
  if (typeof id === "string") {
    return id.toUpperCase();   // o day TS biet id la string
  }
  return id.toFixed(2);        // con lai chac chan la number
}
console.log(inRa("bo"), inRa(3.14159));

Kết quả khi chạy

BO 3.14
  • Narrowing dùng typeof / instanceof / in / kiểm bằng nhau để TS biết kiểu cụ thể.
  • Trong mỗi nhánh đã narrow, TS mở khoá đúng method/field của kiểu đó.
  • Đây là các phép kiểm JavaScript THẬT (chạy lúc runtime), không phải ép kiểu suông.

Mẫu mạnh nhất là discriminated (tagged) union: một union các object, mỗi object có chung một field "nhãn" mang literal khác nhau (vd status). Switch theo field nhãn là TS mở khoá đúng các field riêng của từng nhánh:

discriminated union + switch

type KetQua =
  | { status: "loading" }
  | { status: "success"; data: string }
  | { status: "error"; message: string };

function hien(kq: KetQua): string {
  switch (kq.status) {
    case "loading": return "Dang tai...";
    case "success": return "Co: " + kq.data;      // co data
    case "error":   return "Loi: " + kq.message;  // co message
  }
}
console.log(hien({ status: "success", data: "xong" }));

Kết quả khi chạy

Co: xong

Bấm thử từng nhánh: ở mỗi status, TypeScript chỉ cho đụng đúng field của nhánh đó.

Một biến kq kiểu union trạng thái. Chọn nhánh status, xem TypeScript cho đụng field nào:

if (kq.status === "idle") { ... }

  • kq.status ✓ truy cập được
  • kq.data ✗ lỗi: field này không có ở nhánh "idle"
  • kq.message ✗ lỗi: field này không có ở nhánh "idle"

Chỉ có status; chưa có dữ liệu gì.

Vì sao mẫu này mạnh

Truy cập nhầm field của nhánh khác (vd đọc kq.data ở nhánh error) bị tsc chặn ngay (TS2339). Discriminated union biến "trạng thái có thể sai" thành thứ compiler kiểm giúp - rất hợp để mô hình hoá kết quả tải dữ liệu, ô nhập liệu, hay máy trạng thái.

Còn một mảnh ghép: làm sao để compiler nhắc khi bạn quên xử lý một nhánh? Dùng kiểu never ở Bài 2: ở default, nếu mọi nhánh đã xử lý hết thì biến còn lại có kiểu never; gán nó cho một biến never là hợp lệ. Thêm nhánh mới mà quên xử lý, dòng đó sẽ báo lỗi:

kiểm vét cạn (đủ case → sạch)

type Mau = "do" | "xanh";
function ten(m: Mau): string {
  switch (m) {
    case "do":   return "Do";
    case "xanh": return "Xanh";
    default:
      const _e: never = m;   // du case nen m la never -> OK
      return _e;
  }
}

Thêm "vang" vào Mau nhưng quên xử lý → tsc nhắc

error TS2322: Type '"vang"' is not assignable to type 'never'.
  • Gán nhánh "còn lại" cho một biến never ở default tạo ra kiểm vét cạn.
  • Đủ case thì biến là never, biên dịch sạch; thiếu case thì không phải never nên báo lỗi.
  • Nhờ vậy thêm một trạng thái mới, compiler tự dẫn bạn tới mọi switch cần cập nhật.

Union cho ta mô tả "một trong nhiều khả năng", narrowing cho ta dùng từng khả năng an toàn, và discriminated union cộng never biến trạng thái phức tạp thành thứ compiler kiểm giúp. Đây là bộ công cụ mèo con sẽ dùng hằng ngày để mô hình dữ liệu thật.

Bài tiếp theo: Generics

Tới giờ ta gắn kiểu cho các giá trị cụ thể. Bài sau học generics - hàm và kiểu tham số hoá để viết code tái dùng được mà vẫn an toàn kiểu, nền của mọi thư viện TypeScript.

Câu hỏi thường gặp

Union là "một trong vài kiểu CỤ THỂ" (string | number) - vẫn an toàn, TS bắt bạn xử lý từng khả năng. any là "kiểu gì cũng được" và TẮT kiểm tra. Union giữ lưới an toàn; any vứt nó đi.

Vì TypeScript chỉ cho dùng những gì CHUNG cho mọi nhánh của union. string | number thì toUpperCase chỉ có ở string, nên gọi thẳng là lỗi. Bạn phải narrowing để TS biết chắc đang ở nhánh nào rồi mới dùng method riêng của nhánh đó.

Một field CHUNG cho mọi nhánh, mang kiểu literal khác nhau ở mỗi nhánh - thường đặt tên status, kind, hoặc type. Nhờ field đó, TS phân biệt được các nhánh khi bạn switch, và mở khoá đúng các field riêng của từng nhánh.

Nếu switch đã xử lý hết mọi nhánh, thì ở default biến không còn khả năng nào - kiểu của nó là never, gán cho biến never thì hợp lệ. Khi bạn thêm một nhánh mới mà quên xử lý, biến đó không còn là never nữa, nên dòng gán never báo lỗi - compiler nhắc bạn chỗ thiếu.

Khác nhau. Narrowing dựa trên các phép kiểm THẬT của JavaScript (typeof, instanceof, in) - những phép này CHẠY thật lúc runtime; TypeScript chỉ theo dõi kiểu dựa theo chúng. Còn as (ép kiểu) thì thuần lúc biên dịch, không kiểm gì lúc chạy - nên narrowing an toàn hơn as nhiều.

Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.

Tick những điều em tự tin làm được sau khi học bài này. 0/6

Trả lời vài câu để chắc rằng em đã nắm bài.

Câu 1/3 Điểm: 0

Union type string | number nghĩa là gì?

Bài tập về nhà

  1. 1

    Union cần narrow

    Viết một hàm nhận tham số kiểu string | number và thử gọi một method chỉ có ở string ngay lập tức. Xem tsc báo gì.

    ✅ Hoàn thành khi: Ghi lại lỗi tsc (toUpperCase không có trên number), và một câu vì sao union buộc phải narrow trước.

  2. 2

    Narrow bằng typeof

    Sửa hàm trên: dùng if (typeof x === "string") để tách hai nhánh, mỗi nhánh dùng method đúng kiểu. Chạy thử với một chuỗi và một số.

    ✅ Hoàn thành khi: Hàm chạy đúng cho cả hai loại đầu vào, mỗi nhánh dùng được method riêng của kiểu đã narrow.

  3. 3

    Thiết kế discriminated union

    Mô hình một trạng thái thật (vd tải dữ liệu) bằng một union các object có field status chung mang literal khác nhau, ít nhất 3 nhánh.

    ✅ Hoàn thành khi: Một type union 3+ nhánh, mỗi nhánh có field riêng phù hợp (vd success có data, error có message).

  4. 4

    Switch có narrow

    Viết hàm switch theo status của union trên. Thử truy cập field riêng của một nhánh ở nhầm nhánh khác, xem tsc bắt.

    ✅ Hoàn thành khi: Mỗi case chỉ đụng đúng field của nhánh đó; truy cập nhầm field bị tsc báo (TS2339).

  5. 5

    Vét cạn bằng never

    Thêm một dòng default với const _e: never = m. Sau đó thêm một nhánh mới vào union mà CHƯA xử lý trong switch, xem tsc nhắc.

    ✅ Hoàn thành khi: Khi đủ case thì biên dịch sạch; thêm nhánh mới chưa xử lý thì tsc báo "not assignable to type never" (TS2322).

  6. 6

    Chơi với widget trạng thái

    Mở widget ở Bước 3. Với mỗi status, ghi lại field nào truy cập được, field nào không.

    ✅ Hoàn thành khi: Một bảng 4 dòng (idle/loading/success/error) liệt kê field hợp lệ của từng nhánh.