Type và Interface trong TypeScript
Một trong những câu hỏi được đặt thường xuyên nhất về TypeScript là liệu chúng ta nên sử dụng interface hay type. Câu trả lời cho câu hỏi này, giống như nhiều câu hỏi lập trình khác, là nó phụ thuộc vào tình hình cụ thể. Trong một số trường hợp, một cái có lợi thế rõ rệt hơn cái kia, nhưng trong nhiều trường hợp, chúng có thể thay thế cho nhau.
Chúng ta có hai lựa chọn để định nghĩa các loại trong TypeScript: Type và Interface. Một trong những câu hỏi được đặt thường xuyên nhất về TypeScript là liệu chúng ta nên sử dụng interface hay type.
Câu trả lời cho câu hỏi này, giống như nhiều câu hỏi lập trình khác, là nó phụ thuộc vào tình hình cụ thể. Trong một số trường hợp, một cái có lợi thế rõ rệt hơn cái kia, nhưng trong nhiều trường hợp, chúng có thể thay thế cho nhau.
Trong bài viết này, chúng ta sẽ thảo luận về những điểm khác biệt và tương đồng quan trọng giữa type và interface và khám phá khi nào thì nên sử dụng từng cái.
Hãy bắt đầu từ những kiến thức cơ bản về type và interface.
Types và type aliases
type là một từ khóa trong TypeScript mà chúng ta có thể sử dụng để định nghĩa hình dạng của dữ liệu. Các loại cơ bản trong TypeScript bao gồm:
- String (Chuỗi)
- Boolean (Đúng sai)
- Number (Số)
- Array (Mảng)
- Tuple (Bộ)
- Enum (Kiểu liệt kê)
- Các loại nâng cao
Mỗi loại có các đặc điểm và mục đích riêng biệt, cho phép nhà phát triển lựa chọn loại phù hợp với trường hợp sử dụng cụ thể của họ.
Type aliases trong TypeScript có nghĩa là "một tên cho bất kỳ loại nào." Chúng cung cấp một cách để tạo ra tên mới cho các type hiện có. Type aliases không định nghĩa các type mới; thay vào đó, chúng cung cấp một tên thay thế cho một type hiện có.
Type aliases có thể được tạo bằng cách sử dụng từ khóa type, trỏ đến bất kỳ loại TypeScript hợp lệ nào, bao gồm cả các loại nguyên thủy.
type MyNumber = number
type User = {
id: number
name: string
email: string
}
Trong ví dụ trên, chúng ta tạo ra hai type aliases: MyNumber và User. Chúng ta có thể sử dụng MyNumber làm cách viết tắt cho một loại số và sử dụng type alias User để đại diện cho định nghĩa type của một người dùng.
Bạn có thể tạo ra các type alias sau:
type ErrorCode = string | number
type Answer = string | number
Hai type aliases ở trên đại diện cho các tên thay thế cho cùng một kiểu liên hợp: string | number. Mặc dù loại cơ bản là giống nhau, nhưng các tên khác nhau biểu thị những ý đồ khác nhau, làm cho mã nguồn dễ đọc hơn.
Interface
Trong TypeScript, một interface định nghĩa một contract - hợp đồng mà một đối tượng phải tuân theo. Dưới đây là một ví dụ:
interface Client {
name: string
address: string
}
Chúng ta có thể biểu thị định nghĩa contract cho Client bằng cách sử dụng type annotations như sau:
type Client = {
name: string
address: string
}
Cả hai cách trên đều định nghĩa cùng một hợp đồng cho Client. Tuy nhiên, interface thường được sử dụng khi định nghĩa contract vì chúng có một số tính năng và mục đích đặc biệt, như khả năng mở rộng hợp đồng và hỗ trợ cho tên của thuộc tính động, trong khi type annotations thường được sử dụng khi định nghĩa kiểu cụ thể.
Sự khác biệt giữa type và interface
Trong trường hợp trên, chúng ta có thể sử dụng cả type và interface. Tuy nhiên, có một số tình huống trong đó sử dụng type thay vì interface sẽ tạo ra sự khác biệt.
Primitive types - Kiểu nguyên thủy
Kiểu nguyên thủy là các kiểu tích hợp sẵn trong TypeScript. Chúng bao gồm các loại number, string, boolean, null và undefined.
Chúng ta có thể định nghĩa một type alias cho một loại nguyên thủy như sau:
type Address = string
Chúng ta thường kết hợp kiểu nguyên thủy với union type để định nghĩa một type alias, để làm cho code dễ đọc hơn:
type NullOrUndefined = null | undefined
Tuy nhiên, chúng ta không thể sử dụng một interface để đặt tên thay thế cho một kiểu nguyên thủy. Interface chỉ có thể được sử dụng cho một loại object.
Do đó, khi chúng ta cần định nghĩa một type alias cho kiểu nguyên thủy, chúng ta bắt buộc phải sử dụng type.
Union type
Union type cho phép chúng ta mô tả các giá trị có thể thuộc về một trong một số loại và tạo ra sự kết hợp của các loại nguyên thủy, literal hoặc phức hợp:
type Transport = 'Bus' | 'Car' | 'Bike' | 'Walk'
Union type chỉ có thể được định nghĩa bằng cách sử dụng type. Không có đối tượng tương đương của union type trong một interface. Tuy nhiên, có thể tạo ra một union type mới từ hai interface như sau:
interface CarBattery {
power: number
}
interface Engine {
type: string
}
type HybridCar = Engine | CarBattery
Function types - kiểu hàm
Bằng cách sử dụng type alias, chúng ta cần chỉ định các tham số và loại trả về để định nghĩa kiểu function:
type AddFn = (num1: number, num2:number) => number
Chúng ta cũng có thể sử dụng một interface để đại diện cho kiểu function:
interface IAdd {
(num1: number, num2:number): number
}
Cả type và interface đều định nghĩa được các function tương tự, trừ sự khác biệt về cú pháp giữa interface sử dụng : và => khi sử dụng type. Trong trường hợp này, type được ưa chuộng vì nó ngắn gọn hơn và dễ đọc hơn.
Một lý do khác để sử dụng type khi định nghĩa function là khả năng mà interface thiếu. Khi function trở nên phức tạp hơn, chúng ta có thể tận dụng các tính năng nâng cao như conditional types, mapped types, v.v. Dưới đây là một ví dụ:
type Car = 'ICE' | 'EV'
type ChargeEV = (kws: number) => void
type FillPetrol = (type: string, liters: number) => void
type RefillHandler<A extends Car> = A extends 'ICE'
? FillPetrol
: A extends 'EV'
? ChargeEV
: never
const chargeTesla: RefillHandler<'EV'> = (power) => {
// Triển khai để sạc xe điện (EV)
}
const refillToyota: RefillHandler<'ICE'> = (fuelType, amount) => {
// Triển khai để đổ nhiên liệu cho xe động cơ đốt trong (ICE)
}
Ở đây, chúng ta định nghĩa một loại RefillHandler với conditional type và union type. Nó cung cấp một function thống nhất cho các xử lý EV và ICE một cách an toàn về kiểu dữ liệu. Chúng ta không thể đạt được điều tương tự với interface vì nó không có cách làm tương đương của conditional type và union type.
Declaration merging - Gộp khai báo
Declaration merging là một tính năng đặc biệt của các interface trong TypeScript. Với declaration merging, chúng ta có thể định nghĩa một interface nhiều lần, và trình biên dịch TypeScript sẽ tự động gộp các định nghĩa này thành một định nghĩa duy nhất của interface.
Trong ví dụ sau, hai khai báo Client được gộp lại thành một bởi trình biên dịch TypeScript, và khi sử dụng interface Client, chúng ta có hai thuộc tính:
interface Client {
name: string
}
interface Client {
age: number
}
const harry: Client = {
name: 'Harry',
age: 41,
}
Type aliases không thể được gộp lại theo cách tương tự. Nếu bạn cố gắng định nghĩa lại Client nhiều lần bằng type, như trong ví dụ trên, sẽ xảy ra lỗi:
// Duplicate identifier 'Client'
Khi sử dụng ở những nơi phù hợp, delaration merging có thể rất hữu ích. Một trường hợp sử dụng thông thường của delaration merging là mở rộng định nghĩa type của một thư viện của bên thứ ba để phù hợp với các yêu cầu cụ thể trong một dự án cụ thể.
Nếu bạn cần delaration merging, thì interface là lựa chọn tối ưu.
Extends và intersection
Một interface có thể mở rộng từ một hoặc nhiều interface khác. Bằng cách sử dụng từ khóa extends, một interface mới có thể kế thừa tất cả các properties và methods của một interface hiện có, đồng thời có thể thêm các properties/methods mới.
Ví dụ, chúng ta có thể tạo một interface VIPClient bằng cách mở rộng từ interface Client:
interface VIPClient extends Client {
benefits: string[]
}
Để đạt được kết quả tương tự cho type, chúng ta cần sử dụng toán tử &:
type VIPClient = Client & { benefits: string[] }; // Client là một type
Bạn cũng có thể mở rộng một interface từ một type:
type Client = {
name: string
}
interface VIPClient extends Client {
benefits: string[]
}
Một ngoại lệ là các kiểu liên hợp (union type). Nếu bạn cố gắng mở rộng một interface từ một kiểu liên hợp, bạn sẽ gặp phải lỗi sau:
type Jobs = 'salary worker' | 'retired'
interface MoreJobs extends Jobs {
description: string
}
// An interface can only be extended an object type or intersection of object type with statically known members.
Lỗi xảy ra vì loại liên hợp không được biết trước tĩnh. Định nghĩa của interface cần được biết trước tại thời điểm biên dịch.
Type aliases có thể mở rộng các interface bằng cách sử dụng toán tử &, như dưới đây:
interface Client {
name: string
}
type VIPClient = Client & { benefits: string[] }
Tóm lại, cả interface và type alias có thể được mở rộng. Một interface có thể mở rộng từ một type alias biết trước tĩnh, trong khi một type alias có thể mở rộng từ một interface bằng cách sử dụng toán tử giao điểm.
Làm việc với kiểu tuple
Trong TypeScript, kiểu tuple cho phép chúng ta biểu thị một mảng với một số lượng phần tử cố định, trong đó mỗi phần tử có kiểu dữ liệu riêng của nó. Nó có thể hữu ích khi bạn cần làm việc với các mảng dữ liệu có cấu trúc cố định:
type Member = [name: string, role: string, age: number]
Interfaces không có hỗ trợ trực tiếp cho kiểu tuple. Mặc dù chúng ta có thể tạo một số cách thay thế như trong ví dụ dưới đây, nhưng nó không ngắn gọn hoặc dễ đọc như việc sử dụng kiểu tuple:
interface Member extends Array<string | number> {
0: string
1: string
2: number
}
const peter: Member = ['Harry', 'Dev', 24]
const tom: Member = ['Tom', 30, 'Manager'] // Lỗi: Loại 'number' không gán được cho loại 'string'.
Các tính năng nâng cao
TypeScript cung cấp một loạt các tính năng nâng cao mà không thể tìm thấy trong các interface. Một số tính năng duy nhất trong TypeScript bao gồm:
- Type inferences - Kiểu suy luận: Có thể suy luận kiểu variable và function dựa trên cách chúng được sử dụng. Điều này giúp giảm mã và tăng khả năng đọc hiểu code.
- Conditional type - Các loại điều kiện: Cho phép chúng ta tạo ra biểu thức kiểu phức tạp với các hành vi có điều kiện phụ thuộc vào các type khác.
- Type guards - Kiểu kiểm tra: Được sử dụng để viết các dòng kiểm soát phức tạp dựa trên kiểu của một biến.
- Mapped type - Các loại được ánh xạ: Chuyển đổi một loại đối tượng hiện có thành một loại mới.
- Utility type - Các loại tiện ích: Một bộ các tiện ích có sẵn giúp thao tác với các type.
Hệ thống kiểu ấn tượng trong TypeScript là một trong những lý do chính mà nhiều nhà phát triển ưa thích sử dụng TypeScript.
Khi nào sử dụng type so với interface
Type và interface tương tự nhau nhưng có sự khác biệt tinh tế, như được thể hiện ở phần trước.
Mặc dù hầu hết tất cả các tính năng của interface có sẵn trong type hoặc có tương đương, một ngoại lệ là declaration merging. Interfaces nên được sử dụng khi cần declaration merging, ví dụ như khi mở rộng một thư viện hiện có hoặc tạo một thư viện mới. Ngoài ra, nếu bạn ưa thích kiểu kế thừa OOP, việc sử dụng từ khóa extends với một interface thường được cho là dễ đọc hơn là sử dụng & với type.
Tuy nhiên, rất nhiều tính năng trong type khó hoặc không thể thực hiện được bằng cách sử dụng interface. Ví dụ, TypeScript cung cấp các tính năng phong phú như conditional types, generic types, type guards, advanced types và nhiều tính năng khác. Bạn có thể sử dụng chúng để xây dựng một hệ thống typing có ràng buộc tốt hơn cũng như an toàn hơn. Các interface không thể đạt được điều này.
Trong nhiều trường hợp, cả hai có thể được sử dụng xen kẽ tùy thuộc vào sở thích cá nhân. Tuy nhiên, chúng ta nên sử dụng type trong các trường hợp sử dụng sau đây:
- Để tạo một tên mới cho một kiểu nguyên thủy.
- Để định nghĩa một union type, tuple, function hoặc một kiểu phức tạp khác.
- Để overload - nạp chồng function.
- Để sử dụng conditional types, generic types, type guards, advanced types và nhiều tính năng khác.
So với interface, type biểu đạt phong phú hơn. Nhiều tính năng nâng cao không có sẵn trong các interface và các tính năng này sẽ tiếp tục phát triển khi TypeScript được nâng cấp theo thời gian.
Dưới đây là một ví dụ về tính năng nâng cao mà interface không thể làm được.
type Client = {
name: string
address: string
}
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}
type clientType = Getters<Client>
// tương đương với
// type clientType = {
// getName: () => string
// getAddress: () => string
// }
Bằng cách sử dụng mapped types, template literal types và toán tử keyof, chúng ta đã tạo ra một type tự động tạo các phương thức getter cho bất kỳ kiểu đối tượng nào.
Ngoài ra, nhiều nhà phát triển ưa thích sử dụng type vì chúng khớp tốt với mô hình lập trình hướng functional. Các biểu thức type phong phú giúp dễ dàng đạt được sự kết hợp chức năng, tính bất biến và các khả năng lập trình functional khác một cách an toàn về typing.
Kết luận
Trong bài viết này, chúng ta đã thảo luận về type và interface cùng với sự khác biệt giữa chúng. Mặc dù có một số tình huống mà một cái được ưa chuộng hơn cái kia, trong hầu hết các trường hợp, sự lựa chọn giữa chúng dựa vào sở thích cá nhân.
Mình ưa thích sử dụng type đơn giản vì hệ thống kiểu tuyệt vời. Ý kiến của bạn là gì? Bạn có thể chia sẻ ý kiến của mình trong phần bình luận bên dưới.
