← TypeScript Thực Chiến

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

Interface & structural typing

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

Mô tả hình dạng dữ liệu bằng interface và type alias (optional, readonly, index signature); vì sao TS tương thích theo HÌNH DẠNG chứ không theo tên (structural / duck typing, khác nominal của Java/C#); excess property check gây bất ngờ. Bài under the hood của hệ thống kiểu.

bài trước ta viết kiểu object ngay tại chỗ: { ten: string; tuoi: number }. Khi hình dạng lớn dần và lặp lại nhiều nơi, hãy đặt tên cho nó bằng interface hoặc type:

dat-ten-hinh-dang.ts

interface Meo {
  ten: string;
  tuoi: number;
  mau?: string;          // optional: co cung duoc, thieu cung duoc
  readonly id: number;   // readonly: gan mot lan, khong sua lai
}

type Diem = { x: number; y: number };   // type alias cho object

interface BangDiem {
  [mon: string]: number;   // index signature: khoa string bat ky, gia tri number
}
  • interface và type alias đều mô tả hình dạng một object để tái dùng.
  • Hậu tố ? làm field optional; readonly chặn gán lại (lúc biên dịch).
  • Index signature [key: string]: T mô tả object có khoá động, không cố định.

Đây là điểm "under the hood" hay làm người từ Java/C# bất ngờ. TypeScript so khớp kiểu theo hình dạng (structural typing), không theo tên khai báo. Một object chỉ cần đúng hình dạng là dùng được như Meo, dù chưa từng được khai báo là Meo:

structural.ts

interface Meo {
  ten: string;
  tuoi: number;
}
function gioiThieu(m: Meo): string {
  return m.ten + ", " + m.tuoi + " tuoi";
}

const obj = { ten: "Bo", tuoi: 2, mau: "den" };  // co them mau
console.log(gioiThieu(obj));   // OK! obj du ten+tuoi nen khop hinh dang

Kết quả khi chạy

Bo, 2 tuoi

Nghịch thử: bật tắt field xem object còn "khớp hình dạng" Meo hay không.

Mục tiêu: gán object của mèo con cho một biến kiểu Meo = { ten: string; tuoi: number }. Bật/tắt field xem có lọt không:

Object của mèo con: { ten: string; tuoi: number }

✅ Gán được - hình dạng khớp (field thừa không sao).

Khác với Java/C# (nominal typing)

Ở các ngôn ngữ nominal, một object chỉ là Meo nếu được khai báo đúng tên lớp Meo. TypeScript thì theo "duck typing": "đi như vịt, kêu như vịt thì là vịt" - đủ hình dạng là khớp. Điều này khiến TS rất dễ ghép với code JavaScript có sẵn.

Structural typing nói "thừa field thì bỏ qua" - đúng, nhưng có một ngoại lệ cố ý. Khi bạn gán thẳng một object literal có field thừa, TypeScript siết lại và báo lỗi. Gọi là excess property check:

Gán literal thừa field → lỗi

interface Meo { ten: string; tuoi: number; }
const m: Meo = { ten: "Bo", tuoi: 2, mau: "den" };

tsc báo

error TS2353: Object literal may only specify known properties, and 'mau' does not exist in type 'Meo'.

Vì sao cùng object đó qua biến (Bước 2) thì lọt, mà gán thẳng lại lỗi? Vì khi bạn gõ literal ngay tại chỗ, một field lạ gần như chắc chắn là gõ nhầm - nên TS bắt giúp. Đây là một tính năng, không phải mâu thuẫn.

  • Object literal gán thẳng bị kiểm field thừa (excess property check) để bắt lỗi gõ tên.
  • Cùng object qua một biến trung gian thì theo quy tắc structural thường (thừa field bỏ qua).
  • Đây là lưới an toàn cho lỗi gõ nhầm tên thuộc tính, không phải mâu thuẫn.

Cả hai mô tả hình dạng được, nên với object thường thì chọn cái nào cũng ổn. Hai khác biệt đáng nhớ: interface có thể extends và gộp khai báo; type làm được nhiều hơn với union, intersection và kiểu nâng cao:

extends vs intersection

interface DongVat { ten: string; }
interface Meo extends DongVat { keu: string; }   // interface: extends

type DongVat2 = { ten: string };
type Meo2 = DongVat2 & { keu: string };          // type: giao kieu (&)
  • Object thường: interface hay type đều được, chọn theo thói quen đội nhóm.
  • Cần extends/gộp khai báo, mô tả class: nghiêng về interface.
  • Cần union, intersection, hay kiểu nâng cao sau này: dùng type alias.

Bạn vừa biết cách đặt tên cho hình dạng dữ liệu, và nắm điểm cốt lõi: TypeScript khớp kiểu theo hình dạng, không theo tên. Một nơi structural typing lộ rõ nhất là khi một class hứa sẽ "có hình dạng" của một interface.

Bài tiếp theo: Class trong TypeScript

Bài sau xem class trong TypeScript: access modifier (public/private/protected), parameter property, abstract, và cách một class implements một interface - structural typing ở dạng có tên.

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

Với hình dạng object, hai cái gần như thay thế được nhau. Khác biệt: interface có thể được extends và "gộp khai báo" (declaration merging), hợp khi mô tả object và class. type alias thì linh hoạt hơn cho union, intersection, primitive, tuple và các kiểu nâng cao sau này. Quy tắc gọn: object dùng cái nào cũng được; cần union/giao kiểu thì dùng type.

Phần lớn là tiện: bạn không phải khai báo "object này là Meo" một cách rườm rà, chỉ cần đúng hình dạng. Rủi ro là hai thứ tình cờ cùng hình dạng bị coi là một. Trong thực tế điều đó hiếm khi gây hại, và TypeScript có excess property check (Bước 3) cùng kỹ thuật "branded type" (nâng cao) để siết khi cần.

Đó là excess property check: TypeScript siết riêng với object literal gán thẳng, vì lúc đó field thừa gần như chắc chắn là gõ nhầm. Khi đi qua một biến trung gian, TS dùng quy tắc structural thông thường (thừa field thì bỏ qua). Mẹo: gán literal đúng kiểu sẽ giúp bắt lỗi gõ tên sớm.

Không. readonly chỉ là một kiểm tra lúc biên dịch - và như Bài 1 đã nói, kiểu bị xoá hết khi ra JavaScript (type erasure), nên lúc chạy không có gì chặn việc sửa cả. readonly chống bạn lỡ tay sửa khi viết code, không phải một khoá bảo mật lúc chạy.

Khi khoá (key) không cố định mà động, vd một từ điển điểm số theo tên môn: { [mon: string]: number }. Nó nói "khoá bất kỳ kiểu string, giá trị kiểu number". Dùng khi bạn thật sự không biết trước các khoá; nếu biết, cứ liệt kê field cụ thể để TS bắt lỗi chặt hơn.

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

Structural typing trong TypeScript nghĩa là gì?

Bài tập về nhà

  1. 1

    Đặt tên hình dạng

    Lấy một object trong code của mèo con (hoặc tự nghĩ một cái, vd một cuốn sách). Viết một interface mô tả nó, có ít nhất một field optional và một field readonly.

    ✅ Hoàn thành khi: Một interface biên dịch sạch, dùng được làm kiểu cho biến, có đúng một field optional (?) và một field readonly.

  2. 2

    Khớp theo hình dạng

    Viết một hàm nhận interface ở bài trên. Tạo một object có ĐỦ field cần thiết và THỪA thêm một field lạ, gán vào một biến rồi truyền vào hàm.

    ✅ Hoàn thành khi: Hàm chạy bình thường dù object thừa field, chứng minh TS khớp theo hình dạng (qua biến trung gian).

  3. 3

    Chạm phải excess property check

    Lần này truyền THẲNG một object literal có field thừa vào hàm (không qua biến). Xem tsc báo lỗi gì.

    ✅ Hoàn thành khi: Ghi lại thông báo lỗi excess property (TS2353) và một câu giải thích vì sao literal bị siết còn biến thì không.

  4. 4

    Chơi với widget hình dạng

    Mở widget ở Bước 2. Tìm tổ hợp bật/tắt nhỏ nhất khiến object KHÔNG gán được cho Meo, và một tổ hợp có field thừa mà VẪN gán được.

    ✅ Hoàn thành khi: Hai tổ hợp cụ thể: một cái lỗi (kèm lý do thiếu/sai kiểu), một cái thừa field nhưng vẫn hợp lệ.

  5. 5

    readonly không phải khoá runtime

    Khai báo một interface có field readonly, tạo object, thử gán lại field đó. Xem tsc chặn. Rồi biên dịch ra .js và thử sửa trong JS.

    ✅ Hoàn thành khi: Bằng chứng: tsc báo lỗi readonly (TS2540), nhưng file .js sinh ra sửa được - vì kiểu đã bị xoá.

  6. 6

    interface hay type?

    Nêu hai tình huống: một cái nên dùng interface, một cái buộc phải dùng type alias (vd cần một union).

    ✅ Hoàn thành khi: Hai ví dụ kèm lý do: vì sao tình huống này hợp interface, vì sao tình huống kia cần type.