Hướng dẫn toàn diện về từ khóa 'infer' trong TypeScript, giải thích cách sử dụng nó với kiểu có điều kiện để trích xuất và xử lý kiểu mạnh mẽ, bao gồm các trường hợp sử dụng nâng cao.
Làm chủ TypeScript Infer: Trích xuất Kiểu có Điều kiện để Xử lý Kiểu Nâng cao
Hệ thống kiểu của TypeScript vô cùng mạnh mẽ, cho phép các nhà phát triển tạo ra các ứng dụng vững chắc và dễ bảo trì. Một trong những tính năng chính tạo nên sức mạnh này là từ khóa infer
được sử dụng cùng với các kiểu có điều kiện. Sự kết hợp này cung cấp một cơ chế để trích xuất các kiểu cụ thể từ các cấu trúc kiểu phức tạp. Bài viết này sẽ đi sâu vào từ khóa infer
, giải thích chức năng của nó và giới thiệu các trường hợp sử dụng nâng cao. Chúng ta sẽ khám phá các ví dụ thực tế áp dụng cho các kịch bản phát triển phần mềm đa dạng, từ tương tác API đến xử lý cấu trúc dữ liệu phức tạp.
Kiểu có Điều kiện (Conditional Types) là gì?
Trước khi chúng ta đi sâu vào infer
, hãy cùng xem lại nhanh về kiểu có điều kiện. Kiểu có điều kiện trong TypeScript cho phép bạn định nghĩa một kiểu dựa trên một điều kiện, tương tự như toán tử ba ngôi trong JavaScript. Cú pháp cơ bản là:
T extends U ? X : Y
Điều này có thể được hiểu là: "Nếu kiểu T
có thể gán cho kiểu U
, thì kiểu sẽ là X
; ngược lại, kiểu sẽ là Y
."
Ví dụ:
type IsString<T> = T extends string ? true : false;
type StringResult = IsString<string>; // type StringResult = true
type NumberResult = IsString<number>; // type NumberResult = false
Giới thiệu từ khóa infer
Từ khóa infer
được sử dụng trong mệnh đề extends
của một kiểu có điều kiện để khai báo một biến kiểu có thể được suy luận từ kiểu đang được kiểm tra. Về bản chất, nó cho phép bạn "bắt" một phần của một kiểu để sử dụng sau này.
Cú pháp cơ bản:
type MyType<T> = T extends (infer U) ? U : never;
Trong ví dụ này, nếu T
có thể gán cho một kiểu nào đó, TypeScript sẽ cố gắng suy luận kiểu của U
. Nếu suy luận thành công, kiểu sẽ là U
; ngược lại, nó sẽ là never
.
Các ví dụ đơn giản về infer
1. Suy luận Kiểu trả về của một Hàm
Một trường hợp sử dụng phổ biến là suy luận kiểu trả về của một hàm:
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;
function add(a: number, b: number): number {
return a + b;
}
type AddReturnType = ReturnType<typeof add>; // type AddReturnType = number
function greet(name: string): string {
return `Hello, ${name}!`;
}
type GreetReturnType = ReturnType<typeof greet>; // type GreetReturnType = string
Trong ví dụ này, ReturnType<T>
nhận một kiểu hàm T
làm đầu vào. Nó kiểm tra xem T
có thể gán cho một hàm chấp nhận bất kỳ đối số nào và trả về một giá trị hay không. Nếu có, nó suy luận kiểu trả về là R
và trả về nó. Ngược lại, nó trả về any
.
2. Suy luận Kiểu phần tử của Mảng
Một kịch bản hữu ích khác là trích xuất kiểu phần tử từ một mảng:
type ArrayElementType<T> = T extends (infer U)[] ? U : never;
type NumberArrayType = ArrayElementType<number[]>; // type NumberArrayType = number
type StringArrayType = ArrayElementType<string[]>; // type StringArrayType = string
type MixedArrayType = ArrayElementType<(string | number)[]>; // type MixedArrayType = string | number
type NotAnArrayType = ArrayElementType<number>; // type NotAnArrayType = never
Ở đây, ArrayElementType<T>
kiểm tra xem T
có phải là một kiểu mảng hay không. Nếu có, nó suy luận kiểu phần tử là U
và trả về nó. Nếu không, nó trả về never
.
Các trường hợp sử dụng nâng cao của infer
1. Suy luận tham số của một Constructor
Bạn có thể sử dụng infer
để trích xuất các kiểu tham số của một hàm khởi tạo (constructor):
type ConstructorParameters<T extends new (...args: any) => any> = T extends new (...args: infer P) => any ? P : never;
class Person {
constructor(public name: string, public age: number) {}
}
type PersonConstructorParams = ConstructorParameters<typeof Person>; // type PersonConstructorParams = [string, number]
class Point {
constructor(public x: number, public y: number) {}
}
type PointConstructorParams = ConstructorParameters<typeof Point>; // type PointConstructorParams = [number, number]
Trong trường hợp này, ConstructorParameters<T>
nhận một kiểu hàm khởi tạo T
. Nó suy luận các kiểu của các tham số hàm khởi tạo là P
và trả về chúng dưới dạng một tuple.
2. Trích xuất thuộc tính từ Kiểu đối tượng
infer
cũng có thể được sử dụng để trích xuất các thuộc tính cụ thể từ các kiểu đối tượng bằng cách sử dụng mapped types và conditional types:
type PickByType<T, K extends keyof T, U> = {
[P in K as T[P] extends U ? P : never]: T[P];
};
interface User {
id: number;
name: string;
age: number;
email: string;
isActive: boolean;
}
type StringProperties = PickByType<User, keyof User, string>; // type StringProperties = { name: string; email: string; }
type NumberProperties = PickByType<User, keyof User, number>; // type NumberProperties = { id: number; age: number; }
//Một interface đại diện cho tọa độ địa lý.
interface GeoCoordinates {
latitude: number;
longitude: number;
altitude: number;
country: string;
city: string;
timezone: string;
}
type NumberCoordinateProperties = PickByType<GeoCoordinates, keyof GeoCoordinates, number>; // type NumberCoordinateProperties = { latitude: number; longitude: number; altitude: number; }
Ở đây, PickByType<T, K, U>
tạo ra một kiểu mới chỉ bao gồm các thuộc tính của T
(với các key trong K
) có giá trị có thể gán cho kiểu U
. Mapped type lặp qua các key của T
, và conditional type lọc ra các key không khớp với kiểu được chỉ định.
3. Làm việc với Promises
Bạn có thể suy luận kiểu được giải quyết của một Promise
:
type Awaited<T> = T extends Promise<infer U> ? U : T;
async function fetchData(): Promise<string> {
return 'Data from API';
}
type FetchDataType = Awaited<ReturnType<typeof fetchData>>; // type FetchDataType = string
async function fetchNumbers(): Promise<number[]> {
return [1, 2, 3];
}
type FetchedNumbersType = Awaited<ReturnType<typeof fetchNumbers>>; //type FetchedNumbersType = number[]
Kiểu Awaited<T>
nhận một kiểu T
, được mong đợi là một Promise. Kiểu này sau đó suy luận kiểu được giải quyết U
của Promise, và trả về nó. Nếu T
không phải là một promise, nó trả về T. Đây là một utility type được tích hợp sẵn trong các phiên bản mới hơn của TypeScript.
4. Trích xuất Kiểu của một Mảng các Promise
Việc kết hợp Awaited
và suy luận kiểu mảng cho phép bạn suy luận kiểu được giải quyết bởi một mảng các Promise. Điều này đặc biệt hữu ích khi xử lý Promise.all
.
type PromiseArrayReturnType<T extends Promise<any>[]> = {
[K in keyof T]: Awaited<T[K]>;
};
async function getUSDRate(): Promise<number> {
return 0.0069;
}
async function getEURRate(): Promise<number> {
return 0.0064;
}
const rates = [getUSDRate(), getEURRate()];
type RatesType = PromiseArrayReturnType<typeof rates>;
// type RatesType = [number, number]
Ví dụ này đầu tiên định nghĩa hai hàm bất đồng bộ, getUSDRate
và getEURRate
, mô phỏng việc lấy tỷ giá hối đoái. Utility type PromiseArrayReturnType
sau đó trích xuất kiểu được giải quyết từ mỗi Promise
trong mảng, kết quả là một kiểu tuple mà mỗi phần tử là kiểu đã được await của Promise tương ứng.
Các ví dụ thực tế trong các lĩnh vực khác nhau
1. Ứng dụng Thương mại điện tử
Hãy xem xét một ứng dụng thương mại điện tử nơi bạn lấy chi tiết sản phẩm từ một API. Bạn có thể sử dụng infer
để trích xuất kiểu của dữ liệu sản phẩm:
interface Product {
id: number;
name: string;
price: number;
description: string;
imageUrl: string;
category: string;
rating: number;
countryOfOrigin: string;
}
async function fetchProduct(productId: number): Promise<Product> {
// Mô phỏng cuộc gọi API
return new Promise((resolve) => {
setTimeout(() => {
resolve({
id: productId,
name: 'Example Product',
price: 29.99,
description: 'A sample product',
imageUrl: 'https://example.com/image.jpg',
category: 'Electronics',
rating: 4.5,
countryOfOrigin: 'Canada'
});
}, 500);
});
}
type ProductType = Awaited<ReturnType<typeof fetchProduct>>; // type ProductType = Product
function displayProductDetails(product: ProductType) {
console.log(`Product Name: ${product.name}`);
console.log(`Price: ${product.price} ${product.countryOfOrigin === 'Canada' ? 'CAD' : (product.countryOfOrigin === 'USA' ? 'USD' : 'EUR')}`);
}
fetchProduct(123).then(displayProductDetails);
Trong ví dụ này, chúng ta định nghĩa một interface Product
và một hàm fetchProduct
để lấy chi tiết sản phẩm từ một API. Chúng ta sử dụng Awaited
và ReturnType
để trích xuất kiểu Product
từ kiểu trả về của hàm fetchProduct
, cho phép chúng ta kiểm tra kiểu cho hàm displayProductDetails
.
2. Quốc tế hóa (i18n)
Giả sử bạn có một hàm dịch trả về các chuỗi khác nhau dựa trên ngôn ngữ. Bạn có thể sử dụng infer
để trích xuất kiểu trả về của hàm này để đảm bảo an toàn kiểu:
interface Translations {
greeting: string;
farewell: string;
welcomeMessage: (name: string) => string;
}
const enTranslations: Translations = {
greeting: 'Hello',
farewell: 'Goodbye',
welcomeMessage: (name: string) => `Welcome, ${name}!`,
};
const frTranslations: Translations = {
greeting: 'Bonjour',
farewell: 'Au revoir',
welcomeMessage: (name: string) => `Bienvenue, ${name}!`,
};
function getTranslation(locale: 'en' | 'fr'): Translations {
return locale === 'en' ? enTranslations : frTranslations;
}
type TranslationType = ReturnType<typeof getTranslation>;
function greetUser(locale: 'en' | 'fr', name: string) {
const translations = getTranslation(locale);
console.log(translations.welcomeMessage(name));
}
greetUser('fr', 'Jean'); // Output: Bienvenue, Jean!
Ở đây, TranslationType
được suy luận là interface Translations
, đảm bảo rằng hàm greetUser
có thông tin kiểu chính xác để truy cập các chuỗi đã dịch.
3. Xử lý Phản hồi API
Khi làm việc với các API, cấu trúc phản hồi có thể phức tạp. infer
có thể giúp trích xuất các kiểu dữ liệu cụ thể từ các phản hồi API lồng nhau:
interface ApiResponse<T> {
status: number;
data: T;
message?: string;
}
interface UserData {
id: number;
username: string;
email: string;
profile: {
firstName: string;
lastName: string;
country: string;
language: string;
}
}
async function fetchUser(userId: number): Promise<ApiResponse<UserData>> {
// Mô phỏng cuộc gọi API
return new Promise((resolve) => {
setTimeout(() => {
resolve({
status: 200,
data: {
id: userId,
username: 'johndoe',
email: 'john.doe@example.com',
profile: {
firstName: 'John',
lastName: 'Doe',
country: 'USA',
language: 'en'
}
}
});
}, 500);
});
}
type UserApiResponse = Awaited<ReturnType<typeof fetchUser>>;
type UserProfileType = UserApiResponse['data']['profile'];
function displayUserProfile(profile: UserProfileType) {
console.log(`Name: ${profile.firstName} ${profile.lastName}`);
console.log(`Country: ${profile.country}`);
}
fetchUser(123).then((response) => {
if (response.status === 200) {
displayUserProfile(response.data.profile);
}
});
Trong ví dụ này, chúng ta định nghĩa một interface ApiResponse
và một interface UserData
. Chúng ta sử dụng infer
và chỉ mục kiểu để trích xuất UserProfileType
từ phản hồi API, đảm bảo rằng hàm displayUserProfile
nhận được kiểu chính xác.
Các phương pháp tốt nhất khi sử dụng infer
- Giữ đơn giản: Chỉ sử dụng
infer
khi cần thiết. Lạm dụng nó có thể làm cho mã của bạn khó đọc và khó hiểu hơn. - Ghi chú cho các kiểu của bạn: Thêm nhận xét để giải thích các kiểu có điều kiện và các câu lệnh
infer
của bạn đang làm gì. - Kiểm thử các kiểu của bạn: Sử dụng tính năng kiểm tra kiểu của TypeScript để đảm bảo rằng các kiểu của bạn đang hoạt động như mong đợi.
- Xem xét hiệu suất: Các kiểu có điều kiện phức tạp đôi khi có thể ảnh hưởng đến thời gian biên dịch. Hãy lưu ý đến sự phức tạp của các kiểu của bạn.
- Sử dụng các Utility Types: TypeScript cung cấp một số utility types tích hợp sẵn (ví dụ:
ReturnType
,Awaited
) có thể đơn giản hóa mã của bạn và giảm nhu cầu về các câu lệnhinfer
tùy chỉnh.
Những cạm bẫy thường gặp
- Suy luận không chính xác: Đôi khi, TypeScript có thể suy luận ra một kiểu không như bạn mong đợi. Hãy kiểm tra lại các định nghĩa kiểu và điều kiện của bạn.
- Phụ thuộc vòng: Cẩn thận khi định nghĩa các kiểu đệ quy bằng
infer
, vì chúng có thể dẫn đến các phụ thuộc vòng và lỗi biên dịch. - Các kiểu quá phức tạp: Tránh tạo ra các kiểu có điều kiện quá phức tạp, khó hiểu và khó bảo trì. Hãy chia chúng thành các kiểu nhỏ hơn, dễ quản lý hơn.
Các phương án thay thế cho infer
Mặc dù infer
là một công cụ mạnh mẽ, có những tình huống mà các cách tiếp cận thay thế có thể phù hợp hơn:
- Ép kiểu (Type Assertions): Trong một số trường hợp, bạn có thể sử dụng ép kiểu để chỉ định rõ ràng kiểu của một giá trị thay vì suy luận nó. Tuy nhiên, hãy thận trọng với việc ép kiểu, vì chúng có thể bỏ qua việc kiểm tra kiểu.
- Bảo vệ kiểu (Type Guards): Type guards có thể được sử dụng để thu hẹp kiểu của một giá trị dựa trên các kiểm tra tại thời điểm chạy. Điều này hữu ích khi bạn cần xử lý các kiểu khác nhau dựa trên các điều kiện thời gian chạy.
- Utility Types: TypeScript cung cấp một bộ phong phú các utility types có thể xử lý nhiều tác vụ thao tác kiểu phổ biến mà không cần đến các câu lệnh
infer
tùy chỉnh.
Kết luận
Từ khóa infer
trong TypeScript, khi kết hợp với các kiểu có điều kiện, mở ra các khả năng xử lý kiểu nâng cao. Nó cho phép bạn trích xuất các kiểu cụ thể từ các cấu trúc kiểu phức tạp, giúp bạn viết mã mạnh mẽ, dễ bảo trì và an toàn về kiểu hơn. Từ việc suy luận kiểu trả về của hàm đến trích xuất thuộc tính từ kiểu đối tượng, khả năng là vô tận. Bằng cách hiểu các nguyên tắc và các phương pháp tốt nhất được nêu trong hướng dẫn này, bạn có thể tận dụng infer
hết tiềm năng của nó và nâng cao kỹ năng TypeScript của mình. Hãy nhớ ghi chú cho các kiểu của bạn, kiểm thử chúng kỹ lưỡng và xem xét các cách tiếp cận thay thế khi thích hợp. Làm chủ infer
giúp bạn viết mã TypeScript thực sự biểu cảm và mạnh mẽ, cuối cùng dẫn đến phần mềm tốt hơn.