Bài 11.1: Interfaces trong TypeScript

Bài 11.1: Interfaces trong TypeScript
Chào mừng bạn đến với bài viết tiếp theo trong hành trình khám phá lập trình Front-end hiện đại! Hôm nay, chúng ta sẽ đi sâu vào một trong những khái niệm cốt lõi và mạnh mẽ nhất của TypeScript: Interfaces.
Nếu bạn đã từng làm việc với JavaScript và cảm thấy đôi khi khó kiểm soát cấu trúc của các đối tượng, hoặc lo lắng về việc truyền dữ liệu sai định dạng giữa các phần khác nhau của ứng dụng, thì Interfaces chính là vị cứu tinh mà bạn cần. Chúng mang lại sự rõ ràng, an toàn kiểu dữ liệu và tạo ra những "bản hợp đồng" đáng tin cậy cho code của bạn.
Interfaces là gì? Tại sao chúng lại quan trọng?
Đơn giản mà nói, một Interface trong TypeScript là một cách để định nghĩa hình dạng (shape) của một đối tượng (object). Nó giống như một bản thiết kế hoặc một khuôn mẫu mô tả những thuộc tính (properties) và phương thức (methods) mà một đối tượng được kỳ vọng sẽ có.
Interfaces không tạo ra code JavaScript khi được biên dịch. Chúng chỉ tồn tại trong quá trình phát triển (compile-time) để phục vụ mục đích kiểm tra kiểu dữ liệu. Khi bạn khai báo một biến, tham số hàm, hoặc giá trị trả về của hàm có kiểu là một Interface, TypeScript sẽ kiểm tra xem giá trị đó có khớp với hình dạng được định nghĩa trong Interface hay không.
Tại sao chúng quan trọng?
- An toàn kiểu dữ liệu (Type Safety): Giúp phát hiện lỗi liên quan đến kiểu dữ liệu ngay trong lúc viết code thay vì lúc chạy (runtime).
- Rõ ràng và dễ đọc: Giúp các nhà phát triển khác (và chính bạn sau này) dễ dàng hiểu được cấu trúc dữ liệu mà code đang làm việc.
- Tạo "Hợp đồng" (Contracts): Định nghĩa rõ ràng những gì một đối tượng cần phải có, đặc biệt hữu ích khi làm việc theo nhóm hoặc xây dựng các hệ thống lớn.
- Hỗ trợ công cụ phát triển: IDEs và các công cụ khác có thể sử dụng thông tin từ Interface để cung cấp tính năng tự động hoàn thành (autocompletion), gợi ý code, và kiểm tra lỗi mạnh mẽ hơn.
Hãy cùng đi vào chi tiết cách sử dụng Interfaces nhé!
Định nghĩa Interface cơ bản
Cú pháp để định nghĩa một Interface khá đơn giản, sử dụng từ khóa interface
.
interface KhoaHoc {
ten: string;
thoiLuong: number; // tính bằng giờ
nguoiDay: string;
}
Ở đây, chúng ta định nghĩa một Interface tên là KhoaHoc
với ba thuộc tính: ten
(kiểu string
), thoiLuong
(kiểu number
), và nguoiDay
(kiểu string
).
Bây giờ, bạn có thể sử dụng Interface này để chú thích kiểu cho các biến hoặc tham số:
// Khai báo một biến có kiểu là KhoaHoc
const khoaHocFE: KhoaHoc = {
ten: "Lập trình Web Front-end",
thoiLuong: 200,
nguoiDay: "FullhouseDev"
};
// Ví dụ về một hàm nhận tham số có kiểu KhoaHoc
function hienThiThongTinKhoaHoc(khoaHoc: KhoaHoc): void {
console.log(`Tên khóa học: ${khoaHoc.ten}`);
console.log(`Thời lượng: ${khoaHoc.thoiLuong} giờ`);
console.log(`Người dạy: ${khoaHoc.nguoiDay}`);
}
hienThiThongTinKhoaHoc(khoaHocFE);
Giải thích:
- Chúng ta khai báo biến
khoaHocFE
và gán kiểuKhoaHoc
cho nó. TypeScript ngay lập tức yêu cầu đối tượng được gán phải có đủ 3 thuộc tínhten
,thoiLuong
,nguoiDay
với đúng kiểu dữ liệu đã định nghĩa. Nếu bạn thiếu một thuộc tính hoặc sai kiểu, TypeScript sẽ báo lỗi trước khi bạn chạy code. - Hàm
hienThiThongTinKhoaHoc
được định nghĩa để nhận một tham sốkhoaHoc
có kiểu làKhoaHoc
. Điều này đảm bảo rằng bất kỳ đối tượng nào được truyền vào hàm này đều sẽ có các thuộc tính mà hàm cần để hoạt động (ten
,thoiLuong
,nguoiDay
).
Thuộc tính tùy chọn (Optional Properties)
Đôi khi, một thuộc tính có thể không luôn luôn xuất hiện trên đối tượng. TypeScript cho phép bạn đánh dấu các thuộc tính này là tùy chọn bằng cách thêm dấu ?
sau tên thuộc tính.
interface SinhVien {
maSV: string;
ten: string;
email?: string; // Thuộc tính email là tùy chọn
soDienThoai?: string; // Thuộc tính số điện thoại cũng tùy chọn
}
Bây giờ, bạn có thể tạo các đối tượng SinhVien
có hoặc không có thuộc tính email
và soDienThoai
:
const sv1: SinhVien = {
maSV: "SV001",
ten: "Nguyễn Văn A"
}; // Hợp lệ
const sv2: SinhVien = {
maSV: "SV002",
ten: "Trần Thị B",
email: "b.tran@example.com"
}; // Hợp lệ
const sv3: SinhVien = {
maSV: "SV003",
ten: "Lê Văn C",
soDienThoai: "0987654321"
}; // Hợp lệ
const sv4: SinhVien = {
maSV: "SV004",
ten: "Phạm Thị D",
email: "d.pham@example.com",
soDienThoai: "0123456789"
}; // Hợp lệ
Giải thích:
- Việc thêm dấu
?
vào sau tên thuộc tính (email?
vàsoDienThoai?
) cho TypeScript biết rằng các thuộc tính này có thể có mặt, nhưng không bắt buộc. - Điều này rất hữu ích khi làm việc với dữ liệu không nhất quán hoặc khi một số thông tin chỉ tồn tại trong một số trường hợp cụ thể.
Thuộc tính chỉ đọc (Readonly Properties)
Có những trường hợp bạn muốn đảm bảo rằng một khi một đối tượng được tạo ra, một số thuộc tính của nó sẽ không bao giờ bị thay đổi. Bạn có thể sử dụng từ khóa readonly
để đánh dấu các thuộc tính này.
interface DiemToaDo {
readonly x: number;
readonly y: number;
}
Bây giờ, khi bạn tạo một đối tượng DiemToaDo
, bạn không thể gán lại giá trị cho x
hoặc y
:
const diemGoc: DiemToaDo = { x: 0, y: 0 };
// Thử thay đổi giá trị sẽ gây lỗi biên dịch
// diemGoc.x = 10; // Error: Cannot assign to 'x' because it is a read-only property.
// diemGoc.y = 20; // Error: Cannot assign to 'y' because it is a read-only property.
Giải thích:
- Từ khóa
readonly
chỉ áp dụng trong quá trình kiểm tra kiểu của TypeScript. Nó không ảnh hưởng đến code JavaScript được tạo ra. Tuy nhiên, nó giúp buộc nhà phát triển tuân thủ quy tắc không thay đổi các giá trị này. - Điều này rất hữu ích cho các cấu hình (configuration) không thay đổi, các giá trị hằng số trong một đối tượng, hoặc bất kỳ dữ liệu nào mà bạn muốn đảm bảo tính bất biến (immutability).
Interfaces mô tả Kiểu hàm (Function Types)
Interfaces không chỉ dùng để mô tả hình dạng của các đối tượng dữ liệu. Chúng còn có thể mô tả hình dạng của một hàm – tức là chữ ký của hàm (số lượng và kiểu tham số, kiểu giá trị trả về).
interface PhepTinhHaiSo {
(so1: number, so2: number): number; // Định nghĩa một hàm nhận 2 number và trả về 1 number
}
Bây giờ, bạn có thể sử dụng Interface này để đảm bảo rằng một hàm tuân theo chữ ký đã định nghĩa:
const hamCong: PhepTinhHaiSo = function (x: number, y: number): number {
return x + y;
};
const hamNhan: PhepTinhHaiSo = (x, y) => x * y;
// Thử gán một hàm không khớp chữ ký sẽ gây lỗi
// const hamNoiChuoi: PhepTinhHaiSo = (a: string, b: string) => a + b; // Error: Type '(a: string, b: string) => string' is not assignable to type 'PhepTinhHaiSo'.
Giải thích:
- Cú pháp
(parameterList): returnType
bên trong Interface cho phép bạn định nghĩa kiểu của một hàm. - Bất kỳ hàm nào được gán cho biến có kiểu
PhepTinhHaiSo
đều phải nhận đúng 2 tham số kiểunumber
và trả về giá trị kiểunumber
.
Interfaces mô tả Kiểu có chỉ mục (Indexable Types)
Interfaces cũng có thể mô tả các kiểu dữ liệu mà bạn có thể truy cập bằng một chỉ mục (index), ví dụ như mảng hoặc các đối tượng giống dictionary (từ điển) với khóa là chuỗi.
Chỉ mục số (Numeric Index)
interface DanhSachChuoi {
[index: number]: string; // Định nghĩa rằng khi truy cập bằng chỉ mục số, giá trị trả về là string
}
const myArr: DanhSachChuoi = ["Apple", "Banana", "Cherry"];
console.log(myArr[0]); // Output: Apple
// myArr[1] = 123; // Error: Type 'number' is not assignable to type 'string'.
Giải thích:
[index: number]: string;
nghĩa là khi bạn sử dụng một chỉ mục lànumber
để truy cập vào đối tượng này (ví dụmyArr[0]
), bạn sẽ nhận được một giá trị có kiểu làstring
.- Điều này thường được sử dụng để mô tả các kiểu mảng tùy chỉnh hoặc các cấu trúc dữ liệu tương tự mảng.
Chỉ mục chuỗi (String Index)
interface ThongTinNguoiDung {
[key: string]: any; // Định nghĩa rằng khi truy cập bằng chỉ mục chuỗi (tên thuộc tính), giá trị có thể là bất kỳ kiểu nào
ten: string; // Có thể kết hợp với các thuộc tính được đặt tên cụ thể
tuoi: number;
}
const user: ThongTinNguoiDung = {
ten: "Alice",
tuoi: 30,
diaChi: "123 Đường ABC", // Hợp lệ vì [key: string]: any cho phép thuộc tính chuỗi bất kỳ
ngheNghiep: "Kỹ sư" // Hợp lệ
};
console.log(user.ten); // Output: Alice
console.log(user['diaChi']); // Output: 123 Đường ABC
// const user2: ThongTinNguoiDung = { ten: "Bob" }; // Error: Property 'tuoi' is missing in type '{ ten: string; }' but required in type 'ThongTinNguoiDung'.
Giải thích:
[key: string]: any;
nghĩa là bạn có thể truy cập bất kỳ thuộc tính nào trên đối tượng này bằng một chỉ mục làstring
(tên thuộc tính), và giá trị trả về có thể có kiểu bất kỳ (any
).- Bạn có thể kết hợp chỉ mục chuỗi với các thuộc tính được đặt tên cụ thể (như
ten: string;
,tuoi: number;
). Trong trường hợp này, tất cả các thuộc tính được đặt tên phải phù hợp với kiểu chỉ mục chuỗi (tên thuộc tính là chuỗi), và kiểu của chúng phải tương thích với kiểu trả về của chỉ mục chuỗi (ví dụ:string
vànumber
đều tương thích vớiany
). Một quy tắc mạnh hơn là nếu bạn có thuộc tính được đặt tên, kiểu trả về của chỉ mục chuỗi phải là một kiểu bao trùm tất cả các kiểu của thuộc tính được đặt tên. - Điều này thường được sử dụng cho các đối tượng mà bạn không biết trước tất cả các tên thuộc tính của chúng (ví dụ: dữ liệu từ API), nhưng bạn biết kiểu của các giá trị liên quan đến các tên thuộc tính đó.
Kế thừa (Extending) Interfaces
Interfaces có thể kế thừa từ các Interfaces khác, cho phép bạn xây dựng các Interface mới dựa trên các Interface đã tồn tại và thêm các thuộc tính mới. Điều này giúp tái sử dụng code và tạo ra cấu trúc phân cấp rõ ràng hơn.
Bạn sử dụng từ khóa extends
. Một Interface có thể kế thừa từ một hoặc nhiều Interfaces khác.
// Interface cơ bản
interface DongVat {
ten: string;
soChan: number;
}
// Interface ke thua tu DongVat
interface Cho extends DongVat {
giongLoai: string;
bietSua?: boolean;
}
// Interface ke thua tu nhieu Interface (ví dụ)
interface HienThiDuLieu {
hienThi(): void;
}
interface LuuDuLieu {
luu(): void;
}
interface DoiTuongDuLieu extends HienThiDuLieu, LuuDuLieu {
id: string;
duLieu: any;
}
Giải thích:
- Interface
Cho
kế thừa tất cả các thuộc tính củaDongVat
(ten
,soChan
) và thêm thuộc tính riêng của nó (giongLoai
,bietSua
). - Interface
DoiTuongDuLieu
kế thừa cảHienThiDuLieu
vàLuuDuLieu
, nghĩa là một đối tượng thuộc kiểuDoiTuongDuLieu
phải có thuộc tínhid
,duLieu
và cả hai phương thứchienThi()
vàluu()
.
Ví dụ sử dụng kế thừa:
const choBuddy: Cho = {
ten: "Buddy",
soChan: 4,
giongLoai: "Golden Retriever",
bietSua: true
};
// const dongVatChung: DongVat = choBuddy; // Hợp lệ, vì Cho chứa tất cả thuộc tính của DongVat
// const doiTuongQuanLy: DoiTuongDuLieu = {
// id: "item-001",
// duLieu: { ten: "Sản phẩm A", gia: 100 },
// hienThi: () => { console.log("Hiển thị dữ liệu..."); },
// luu: () => { console.log("Lưu dữ liệu..."); }
// };
Giải thích:
- Đối tượng
choBuddy
phải thỏa mãn cả InterfaceDongVat
vàCho
. - Đối tượng quản lý dữ liệu phải có đủ các thuộc tính và phương thức từ cả
HienThiDuLieu
vàLuuDuLieu
, cùng với các thuộc tính riêng củaDoiTuongDuLieu
.
Lớp (Classes) triển khai (Implementing) Interfaces
Một trong những ứng dụng quan trọng nhất của Interfaces là để các lớp (classes) triển khai (implement) chúng. Khi một lớp triển khai một Interface, nó phải đảm bảo rằng nó có tất cả các thuộc tính và phương thức được định nghĩa trong Interface đó. Điều này tạo ra một bản hợp đồng chặt chẽ giữa Interface và lớp triển khai.
Bạn sử dụng từ khóa implements
.
// Interface định nghĩa khả năng phát ra âm thanh
interface CoThePhatAmThanh {
amLuong: number;
phatAmThanh(loaiAmThanh: string): void;
}
// Lớp Loa trien khai Interface CoThePhatAmThanh
class Loa implements CoThePhatAmThanh {
amLuong: number;
constructor(amLuongMacDinh: number) {
this.amLuong = amLuongMacDinh;
}
phatAmThanh(loaiAmThanh: string): void {
console.log(`Đang phát âm thanh: ${loaiAmThanh} (Âm lượng: ${this.amLuong})`);
}
}
// Lớp Micro trien khai Interface CoThePhatAmThanh
class Micro implements CoThePhatAmThanh {
amLuong: number; // Micro cũng có thuộc tính âm lượng, nhưng nó là độ nhạy
constructor(doNhay: number) {
this.amLuong = doNhay; // Sử dụng amLuong để lưu độ nhạy
}
phatAmThanh(loaiAmThanh: string): void {
console.log(`Micro đang thu âm "${loaiAmThanh}" (Độ nhạy: ${this.amLuong})`);
}
}
const loaMayTinh = new Loa(70);
const microUSB = new Micro(50);
loaMayTinh.phatAmThanh("Nhạc Pop"); // Output: Đang phát âm thanh: Nhạc Pop (Âm lượng: 70)
microUSB.phatAmThanh("Giọng nói"); // Output: Micro đang thu âm "Giọng nói" (Độ nhạy: 50)
// Bạn có thể tạo một mảng các đối tượng thỏa mãn Interface
const thietBiAmThanh: CoThePhatAmThanh[] = [loaMayTinh, microUSB];
thietBiAmThanh.forEach(thietBi => {
thietBi.phatAmThanh("Kiểm tra âm thanh");
});
// Output:
// Đang phát âm thanh: Kiểm tra âm thanh (Âm lượng: 70)
// Micro đang thu âm "Kiểm tra âm thanh" (Độ nhạy: 50)
// Thử tạo một lớp không implement đủ sẽ báo lỗi
// class MayTinh implements CoThePhatAmThanh {
// // Thiếu amLuong và phatAmThanh() - TypeScript sẽ báo lỗi
// }
Giải thích:
- Interface
CoThePhatAmThanh
định nghĩa rằng bất kỳ đối tượng nào triển khai nó đều phải có một thuộc tính sốamLuong
và một phương thứcphatAmThanh
nhận một chuỗi và trả vềvoid
. - Lớp
Loa
vàMicro
sử dụng từ khóaimplements CoThePhatAmThanh
để cam kết sẽ cung cấp đầy đủ các thành viên được yêu cầu bởi Interface. Nếu bạn bỏ sótamLuong
hoặcphatAmThanh
trong một trong hai lớp này, TypeScript sẽ báo lỗi biên dịch. - Điều này cho phép bạn viết code làm việc với kiểu
CoThePhatAmThanh
mà không cần quan tâm đối tượng cụ thể làLoa
hayMicro
, miễn là nó thực hiện đúng "hợp đồng" của Interface. Đây là một khía cạnh quan trọng của lập trình hướng đối tượng và thiết kế code linh hoạt.
Sự khác biệt (và tương đồng) giữa Interfaces và Types
TypeScript cũng có một khái niệm tương tự là Type Aliases (sử dụng từ khóa type
). Đôi khi chúng trông rất giống Interfaces và có thể thay thế cho nhau, nhưng có một số khác biệt chính cần lưu ý:
Mở rộng (Extensibility): Interfaces có thể được "mở lại" (re-opened) và thêm các thành viên mới sau khi đã định nghĩa. Type Aliases thì không.
interface HopDong { tenHopDong: string; } // Sau đó ở một file hoặc dòng code khác, bạn có thể "mở rộng" nó interface HopDong { ngayKy: Date; } // Bây giờ, một đối tượng kiểu HopDong phải có cả tenHopDong và ngayKy const hd: HopDong = { tenHopDong: "Hợp đồng A", ngayKy: new Date() }; // Hợp lệ // Với type aliases thì không làm được như vậy type CauTrucDuLieu = { id: string }; // type CauTrucDuLieu = { value: number }; // Lỗi: Duplicate identifier 'CauTrucDuLieu'.
Khả năng "mở lại" này làm cho Interfaces tự nhiên hơn khi sử dụng trong các tình huống cần mở rộng khai báo, ví dụ như khi làm việc với các thư viện bên thứ ba hoặc khi bạn muốn chia nhỏ khai báo kiểu ra nhiều file.
Triển khai (Implementation): Chỉ Interfaces mới có thể được
implements
bởi classes. Type Aliases không thể.interface MoHinh { ve(): void; } class HinhTron implements MoHinh { ve() { console.log("Vẽ hình tròn"); } } // type MoHinhKhac = { ve(): void }; // class HinhVuong implements MoHinhKhac { // Error: A class can only implement another class or interface. // ve() { console.log("Vẽ hình vuông"); } // }
Đây là một khác biệt quan trọng khi bạn thiết kế các lớp tuân theo một cấu trúc cụ thể.
Khả năng biểu diễn: Type Aliases có thể biểu diễn nhiều kiểu dữ liệu hơn Interfaces, bao gồm union types, intersection types, primitive types, tuples, v.v.
type ID = string | number; // Union type type Diem = [number, number]; // Tuple type KetQuaXuLy = HopDong & HienThiDuLieu; // Intersection type (kết hợp các Interface) // Interfaces không thể biểu diễn trực tiếp các kiểu như union, tuple,...
Khi nào dùng Interface, khi nào dùng Type?
- Sử dụng Interface khi bạn muốn định nghĩa hình dạng của một đối tượng (object) hoặc lớp (class), đặc biệt nếu bạn có ý định sử dụng
implements
hoặc cần khả năng mở rộng khai báo. - Sử dụng Type Aliases khi bạn cần định nghĩa các kiểu dữ liệu phức tạp hơn (union, intersection, tuple), tạo tên gọi ngắn gọn cho các kiểu có sẵn, hoặc khi bạn không làm việc với hình dạng đối tượng/lớp một cách trực tiếp.
Trong nhiều trường hợp đơn giản chỉ định nghĩa hình dạng đối tượng, bạn có thể sử dụng cả hai. Tuy nhiên, một quy tắc chung được cộng đồng khuyến khích là ưu tiên sử dụng Interfaces khi định nghĩa hình dạng đối tượng và sử dụng Type Aliases cho các mục đích khác.
Comments