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.