Types vs Interfaces trong TypeScript

16 min read

Chúng ta có hai lựa chọn để định nghĩa kiểu trong TypeScript: typesinterfaces. Một trong những câu hỏi thường gặp nhất về TypeScript là liệu chúng ta nên sử dụng interfaces hay types.

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à “tùy thuộc”. Trong một số trường hợp, một phương pháp có lợi thế rõ ràng hơn phương pháp còn lại, nhưng trong nhiều trường hợp, chúng có thể hoán đổi cho nhau.

Trong bài viết này, tôi sẽ thảo luận về những điểm khác biệt và tương đồng chính giữa typesinterfaces cũng như xem xét khi nào thì phù hợp để sử dụng từng loại.

Hãy bắt đầu với những khái niệm cơ bản về typesinterfaces.

1. Types and type aliases

type là từ khóa trong TypeScript mà chúng ta có thể sử dụng để định nghĩa cấu trúc của dữ liệu. Các type cơ bản trong TypeScript bao gồm:

  • String
  • Boolean
  • Number
  • Array
  • Tuple
  • Enum
  • Advanced Type

Mỗi loại có các tính năng và mục đích riêng, cho phép các lập trình viên chọn kiểu phù hợp cho trường hợp sử dụng cụ thể của họ.

Type aliases trong TypeScript có nghĩa là “một tên đại diện cho bất kỳ kiểu nào.” Chúng cung cấp một cách tạo tên mới cho các kiểu hiện có. Type aliases không định nghĩa type mới; thay vào đó, chúng cung cấp một tên thay thế cho type hiện có.

Type aliases có thể được tạo bằng từ khóa type, và có thể tham chiếu đến bất kỳ type hợp lệ nào trong TypeScript, bao gồm cả primitive types.

type MyNumber = number;
type User = {
  id: number;
  name: string;
  email: string;
}

Trong ví dụ trên, chúng ta tạo hai type aliases: MyNumberUser. Chúng ta có thể sử dụng MyNumber như một cách viết tắt cho kiểu số (number), và sử dụng type aliases User để đại diện cho định nghĩa type của một người dùng (user).

Khi chúng ta nói “types versus interfaces,” ý muốn nói đến “type aliases so với interfaces.” Ví dụ, bạn có thể tạo type aliases 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 union type: string | number. Mặc dù kiểu cơ bản giống nhau, nhưng các tên gọi khác nhau thể hiện các ý định khác nhau, giúp mã nguồn trở nên dễ đọc hơn.

2. Interfaces in TypeScript

Trong TypeScript, interface định nghĩa một quy tắc 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 diễn cùng một định nghĩa Client bằng cách sử dụng type annotations như sau:

type Client = {
    name: string;
    address: string;
};

3. Differences between types and interfaces


Đối với trường hợp trên, chúng ta có thể sử dụng cả type hoặc interface. Tuy nhiên, có một số tình huống mà việc sử dụng type thay vì interface tạo ra sự khác biệt.

3.1. Primitive types

Primitive types là các kiểu dữ liệu có sẵn trong TypeScript. Chúng bao gồm các kiểu number, string, boolean, null, và undefined.

Chúng ta có thể định nghĩa một type alias cho một primitive type như sau:

type Address = string;

Chúng ta thường kết hợp primitive type với union type để định nghĩa một type alias, giúp mã nguồn dễ đọc hơn:

type NullOrUndefined = null | undefined;

Tuy nhiên, chúng ta không thể sử dụng interface để làm alias cho primitive type. Interface chỉ có thể được sử dụng cho kiểu đối tượng (object type).

Do đó, khi cần định nghĩa alias cho primitive type, chúng ta sử dụng type.

3.2. Union types

Union types cho phép chúng ta mô tả các giá trị có thể thuộc một trong nhiều kiểu và tạo ra các hợp nhất của các primitive type, hoặc complex type:

type Transport = 'Bus' | 'Car' | 'Bike' | 'Walk';

Union type chỉ có thể được định nghĩa bằng type. Không có tương đương của Union type trong interface. Tuy nhiên, chúng ta có thể tạo một Union type mới từ hai interface, như sau:

interface CarBattery {
  power: number;
}
interface Engine {
  type: string;
}
type HybridCar = Engine | CarBattery;

3.3. Function types

Trong TypeScript, function type đại diện cho chữ ký kiểu của một hàm. Sử dụng type alias, chúng ta cần chỉ định các tham số và kiểu trả về để định nghĩa một kiểu hàm. Dưới đây là ví dụ:

type AddFn =  (num1: number, num2:number) => number;

Chúng ta cũng sử dụng interface để đại diện cho function type

interface IAdd {
   (num1: number, num2:number): number;
}

Cả typeinterface đều có thể định nghĩa function types, nhưng có sự khác biệt nhỏ về cú pháp: interface sử dụng dấu : trong khi type sử dụng dấu =>. type thường được ưa chuộng hơn trong trường hợp này vì cú pháp ngắn gọn hơn và do đó dễ đọc hơn.

Một lý do khác để sử dụng type khi định nghĩa function type là vì nó có khả năng vượt trội mà interface không có. Khi hàm 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 của kiểu, chẳng hạn 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) => {
    // Implementation for charging electric cars (EV)
};
const refillToyota: RefillHandler<'ICE'> = (fuelType, amount) => {
    // Implementation for refilling internal combustion engine cars (ICE)
};

Dưới đây, chúng ta định nghĩa một kiểu RefillHandler với conditional type và union type. Nó cung cấp một chữ ký hàm thống nhất cho các bộ xử lý EV và ICE một cách an toàn về kiểu (type-safe). Chúng ta không thể đạt được điều tương tự với interfaceinterface không có các kiểu điều kiện và hợp tương đương.

3.4. Declaration merging

Declaration merging là một tính năng độc quyền của 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 hợp nhất các định nghĩa này thành một định nghĩa interface duy nhất.
Dưới đây là ví dụ, trong đó hai định nghĩa interface Client được trình biên dịch TypeScript hợp nhất thành một, và chúng ta có hai thuộc tính khi sử dụng interface Client:

interface Client { 
    name: string; 
}

interface Client {
    age: number;
}

const harry: Client = {
    name: 'Harry',
    age: 41
}

type aliases không thể được hợp nhất theo cách tương tự. Nếu bạn cố gắng định nghĩa Client nhiều lần, như trong ví dụ trên, một lỗi sẽ được throw:

Khi được sử dụng đúng cách, declaration merging có thể rất hữu ích. Một trường hợp sử dụng phổ biến của declaration merging là mở rộng định nghĩa kiểu của thư viện bên thứ ba để phù hợp với nhu cầu của dự án cụ thể.

Nếu bạn cần kết hợp các khai báo, interface là sự lựa chọn phù hợp.

3.5. Extends vs Intersection

Một interface có thể mở rộng một hoặc nhiều interface. Sử dụng từ khóa extends, một interface mới có thể kế thừa tất cả các thuộc tính và phương thức của một interface hiện có đồng thời thêm các thuộc tính mới.

Ví dụ, chúng ta có thể tạo một interface VIPClient bằng cách mở rộng interface Client:

interface VIPClient extends Client {
    benefits: string[]
}

Để đạt được kết quả tương tự cho types, chúng ta cần sử dụng toán tử giao nhau (intersection operator):

type VIPClient = Client & {benefits: string[]}; // Client is a type


Bạn cũng có thể mở rộng một interface từ một type alias với các property được biết trước tại thời điểm biên dịch:

type Client = {
    name: string;
};

interface VIPClient extends Client {
    benefits: string[]
}

Ngoại lệ là union types. Nếu bạn cố gắng mở rộng một interface từ một union type, bạn sẽ nhận được lỗi sau:

type Jobs = 'salary worker' | 'retired';

interface MoreJobs extends Jobs {
  description: string;
}

Lỗi này xảy ra vì union type không được biết trước tại thời điểm biên dịch. Định nghĩa interface cần phải đượ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ử giao nhau (&), như dưới đây:

interface Client {
    name: string;
}
Type VIPClient = Client & { benefits: string[]};

Tóm lại, cả interfacetype alias đều có thể được mở rộng. Một interface có thể mở rộng một type alias được biết trước tại thời điểm biên dịch, trong khi một type alias có thể mở rộng một interface bằng cách sử dụng toán tử giao nhau (&).

3.6. Handling conflicts when extending

Một sự khác biệt khác giữa typeinterface là cách xử lý các xung đột khi bạn cố gắng mở rộng từ một kiểu với cùng một tên thuộc tính.

Khi mở rộng các interface, việc sử dụng cùng một khóa thuộc tính không được phép, như trong ví dụ dưới đây:

interface Person {
  getPermission: () => string;
}

interface Staff extends Person {
   getPermission: () => string[];
}

Một lỗi sẽ được throw ra vì một xung đột được phát hiện.

type aliases xử lý các xung đột theo cách khác. Trong trường hợp một type alias mở rộng một kiểu khác với cùng một khóa thuộc tính, nó sẽ tự động hợp nhất tất cả các thuộc tính thay vì ném lỗi.

Dưới đây là ví dụ về cách toán tử giao nhau (&) hợp nhất các chữ ký phương thức của hai khai báo getPermission, và toán tử typeof được sử dụng để thu hẹp union type nhằm đảm bảo giá trị trả về được an toàn về kiểu:

type Person = {
  getPermission: (id: string) => string;
};

type Staff = Person & {
   getPermission: (id: string[]) => string[];
};

const AdminStaff: Staff = {
  getPermission: (id: string | string[]) =>{
    return (typeof id === 'string'?  'admin' : ['admin']) as string[] & string;
  }
}

Quan trọng là cần lưu ý rằng việc giao nhau các thuộc tính của hai kiểu có thể dẫn đến kết quả không mong muốn. Trong ví dụ dưới đây, thuộc tính name cho kiểu mở rộng Staff trở thành never, vì nó không thể đồng thời là stringnumber:

type Person = {
    name: string
};

type Staff = person & {
    name: number
};
// error: Type 'string' is not assignable to type 'never'.(2322)
const Harry: Staff = { name: 'Harry' };

Tóm lại, các interface sẽ phát hiện các xung đột về tên thuộc tính hoặc phương thức tại thời điểm biên dịch và sinh lỗi, trong khi type intersections sẽ hợp nhất các thuộc tính hoặc phương thức mà không gây lỗi. Do đó, nếu chúng ta cần overload các hàm, nên sử dụng type aliases.

3.7. Prefer extends over intersection

Thường thì, khi sử dụng interface, TypeScript sẽ hiển thị hình dạng của interface tốt hơn trong các thông báo lỗi, gợi ý và IDEs. Nó cũng dễ đọc hơn, bất kể bạn kết hợp hay mở rộng bao nhiêu loại.

So với type alias sử dụng giao nhau của hai hoặc nhiều kiểu như type A = B & C;, và sau đó bạn sử dụng alias đó trong một giao nhau khác như type X = A & D;, TypeScript có thể gặp khó khăn trong việc hiển thị cấu trúc của kiểu kết hợp, làm cho việc hiểu hình dạng của kiểu từ các thông báo lỗi trở nên khó khăn hơn.

TypeScript lưu trữ kết quả của mối quan hệ đã được đánh giá giữa các interface, chẳng hạn như việc một interface có mở rộng một interface khác hay không hoặc hai interface có tương thích với nhau không. Cách tiếp cận này cải thiện hiệu suất tổng thể khi mối quan hệ giống nhau được tham chiếu trong tương lai.

Ngược lại, khi làm việc với intersection types, TypeScript không lưu trữ các mối quan hệ này. Mỗi khi một giao nhau kiểu được sử dụng, TypeScript phải đánh giá lại toàn bộ giao nhau, điều này có thể dẫn đến các vấn đề về hiệu suất.

Vì những lý do này, nên sử dụng interface extends thay vì dựa vào intersection types.

3.8. Implementing classes using interfaces or type aliases

Trong TypeScript, chúng ta có thể triển khai một class bằng cách sử dụng hoặc interface hoặc type alias:

interface Person {
  name: string;
  greet(): void;
}

class Student implements Person {
  name: string;
  greet() {
    console.log('hello');
  }
}

type Pet = {
  name: string;
  run(): void;
};

class Cat implements Pet {
  name: string;
  run() {
    console.log('run');
  }
}

Như đã được chỉ ra ở trên, cả interfacetype alias đều có thể được sử dụng để implement một class tương tự nhau; điểm khác biệt duy nhất là chúng ta không thể implement một union type.

type primaryKey = { key: number; } | { key: string; };

// can not implement a union type
class RealKey implements primaryKey {
  key = 1
}


Trong ví dụ trên, trình biên dịch TypeScript ném lỗi vì một lớp đại diện cho một cấu trúc dữ liệu cụ thể, trong khi union type có thể là một trong nhiều kiểu dữ liệu khác nhau.

3.9. Working with tuple types



Trong TypeScript, tuple type cho phép chúng ta biểu diễn một mảng với số lượng phần tử cố định, trong đó mỗi phần tử có kiểu dữ liệu riêng. Điều này có thể rất 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 TeamMember = [name: string, role: string, age: number];

Vì tuple type có độ dài cố định và mỗi vị trí trong tuple có kiểu dữ liệu đã được chỉ định, TypeScript sẽ phát sinh lỗi nếu bạn cố gắng thêm, xóa hoặc thay đổi các phần tử theo cách vi phạm cấu trúc này.

const member: TeamMember = ['Alice', ‘Dev’, 28];
member[3]; // Error: Tuple type '[string, string, number]' of length '3' has no element at index '3'.

Các interface không hỗ trợ trực tiếp cho tuple type. Mặc dù chúng ta có thể tạo một số cách giải quyết như trong ví dụ dưới đây, nhưng cách làm này không gọn gàng hoặc dễ đọc bằng việc sử dụng tuple type.

interface ITeamMember extends Array<string | number> 
{
 0: string; 1: string; 2: number 
}

const peter: ITeamMember = ['Harry', 'Dev', 24];
const Tom: ITeamMember = ['Tom', 30, 'Manager']; //Error: Type 'number' is not assignable to type 'string'.

Khác với tuple type, interface này mở rộng kiểu Array tổng quát, điều này cho phép nó có bất kỳ số lượng phần tử nào ngoài ba phần tử đầu tiên. Điều này là vì mảng trong TypeScript là động, và bạn có thể truy cập hoặc gán giá trị cho các chỉ số ngoài những chỉ số được định nghĩa rõ ràng trong interface.

const peter: ITeamMember = [’Peter’, 'Dev', 24];
console.log(peter[3]); // No error, even though this element is undefined.

3.10. Advanced type features

TypeScript cung cấp một loạt các tính năng kiểu nâng cao mà không thể tìm thấy trong interface. Một số tính năng độc đáo trong TypeScript bao gồm:

  • Suy diễn kiểu (Type Inferences): TypeScript có khả năng suy diễn kiểu của biến và hàm dựa trên cách sử dụng của chúng. Điều này giúp giảm lượng mã và cải thiện tính dễ đọc.
  • Kiểu điều kiện (Conditional Types): Cho phép tạo các biểu thức kiểu phức tạp với các hành vi điều kiện phụ thuộc vào các kiểu khác.
  • Bảo vệ kiểu (Type Guards): Được sử dụng để viết các điều kiện điều khiển phức tạp dựa trên kiểu của một biến.
  • Kiểu ánh xạ (Mapped Types): Chuyển đổi một kiểu đối tượng hiện có thành một kiểu mới.
  • Kiểu tiện ích (Utility Types): Một tập hợp các công cụ có sẵn giúp thao tác với các kiểu.

Hệ thống kiểu của TypeScript liên tục phát triển với mỗi bản phát hành mới, biến nó thành một bộ công cụ mạnh mẽ và phức tạp. Hệ thống kiểu ấn tượng là một trong những lý do chính khiến nhiều nhà phát triển ưa chuộng việc sử dụng TypeScript.

3.11. When to use types vs. interfaces

Type aliases và interface có nhiều điểm tương đồng nhưng cũng có những khác biệt tinh tế, như đã được trình bày trong các phần trước.

Trong khi hầu hết các tính năng của interface đều có sẵn trong type hoặc có các tương đương, một ngoại lệ là declaration merging. Interface nên được sử dụng khi cần kết hợp khai báo, chẳng hạn như mở rộng một thư viện hiện có hoặc viết một thư viện mới. Hơn nữa, nếu bạn thích phong cách kế thừa hướng đối tượng, việc sử dụng từ khóa extends với interface thường dễ đọc hơn so với việc sử dụng giao điểm với alias kiểu.

Các interface với extends cho phép trình biên dịch hoạt động hiệu quả hơn so với alias kiểu với giao điểm.

Tuy nhiên, nhiều tính năng trong type là khó hoặc không thể đạt được với interface. Ví dụ, TypeScript cung cấp nhiều tính năng phong phú như kiểu điều kiện (conditional types), kiểu tổng quát (generic types), bảo vệ kiểu (type guards), kiểu nâng cao (advanced types) và nhiều hơn nữa. Bạn có thể sử dụng chúng để xây dựng một hệ thống kiểu được ràng buộc chặt chẽ để làm cho ứng dụng của bạn có kiểu mạnh mẽ. Interface không thể đạt được điều này.

Trong nhiều trường hợp, bạn có thể sử dụng chúng thay thế cho nhau tùy thuộc vào sở thích cá nhân. Tuy nhiên, chúng ta nên sử dụng alias kiểu trong các trường hợp sau:

  • Để tạo một tên mới cho một kiểu nguyên thủy.
  • Để định nghĩa kiểu union, kiểu tuple, kiểu hàm, hoặc kiểu phức tạp khác.
  • Để quá tải hàm (function overloading).
  • Để sử dụng các kiểu ánh xạ, kiểu điều kiện, bảo vệ kiểu hoặc các tính năng kiểu nâng cao khác.

So với interface, type có tính biểu đạt cao hơn. Nhiều tính năng kiểu nâng cao không có sẵn trong interface, và những tính năng đó tiếp tục phát triển khi TypeScript tiến hóa.

Dưới đây là ví dụ về tính năng kiểu nâng cao mà interface không thể đạt đượ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>;

Sử dụng mapped type, template literal types, và toán tử keyof, chúng ta đã tạo ra một kiểu tự động sinh ra 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 việc sử dụng type vì chúng phù hợp với nguyên tắc lập trình hàm. Cú pháp kiểu phong phú giúp dễ dàng đạt được sự kết hợp hàm, tính bất biến và các khả năng lập trình hàm khác theo cách an toàn kiểu.

4. Conclusion

Trong bài viết này, chúng ta đã thảo luận về type alias và interface cũng như sự khác biệt giữa chúng. Mặc dù có một số tình huống trong đó một loại có thể được ưa chuộng hơn loại kia, nhưng trong hầu hết các trường hợp, việc chọn giữa chúng chủ yếu dựa vào sở thích cá nhân.

Tôi nghiêng về việc sử dụng types đơn giản vì hệ thống kiểu mạnh mẽ của TypeScript.

5. Resource

https://viblo.asia/p/type-vs-interface-trong-typescript-gGJ599Gp5X2

https://www.typescriptlang.org/docs/handbook/basic-types.html

https://www.w3schools.com/typescript/typescript_aliases_and_interfaces.php

Avatar photo

Leave a Reply

Your email address will not be published. Required fields are marked *