Bài 13.4: Creating custom utility types trong TypeScript

Bài 13.4: Creating custom utility types trong TypeScript
TypeScript mang đến cho chúng ta một kho báu các Utility Types sẵn có như Partial
, Readonly
, Pick
, Omit
, Exclude
, Extract
,... Chúng vô cùng mạnh mẽ và giúp chúng ta thao tác với các kiểu dữ liệu một cách linh hoạt và an toàn. Tuy nhiên, trong thực tế phát triển, đôi khi các utility types có sẵn vẫn chưa đủ để giải quyết những bài toán đặc thù hoặc phức tạp mà chúng ta gặp phải.
Đây chính là lúc quyền năng của việc tạo ra các custom utility types tỏa sáng! Bằng cách tự định nghĩa các utility types, chúng ta có thể giải quyết vấn đề một cách trực tiếp và chính xác với nhu cầu của mình, biến đổi các kiểu dữ liệu hiện có thành dạng mong muốn, từ đó tăng cường khả năng tái sử dụng, giảm thiểu code lặp (DRY - Don't Repeat Yourself) và nâng cao an toàn kiểu trong toàn bộ dự án.
Bài viết này sẽ đi sâu vào cách chúng ta có thể tự tay xây dựng những utility types tùy chỉnh của riêng mình, dựa trên những kỹ thuật cốt lõi của TypeScript như Mapped Types, Conditional Types và từ khóa infer
.
Hiểu về Nền Tảng: Mapped Types và Conditional Types
Trước khi lặn sâu vào việc tạo custom utility types, hãy nhắc lại một chút về hai kỹ thuật nền tảng quan trọng nhất:
- Mapped Types (Kiểu Ánh Xạ): Cho phép bạn tạo ra một kiểu mới bằng cách lặp qua các thuộc tính của một kiểu dữ liệu khác và áp dụng một biến đổi lên từng thuộc tính đó. Cú pháp cơ bản trông giống như lặp qua một object:
[K in keyof T]
. - Conditional Types (Kiểu Điều Kiện): Cho phép bạn định nghĩa một kiểu dữ liệu dựa trên một điều kiện kiểm tra mối quan hệ giữa các kiểu. Cú pháp sử dụng
extends
và toán tử ba ngôi:TypeA extends TypeB ? TypeC : TypeD
. NếuTypeA
có thể gán được choTypeB
, kiểu kết quả làTypeC
, ngược lại làTypeD
.
Kết hợp hai kỹ thuật này cùng với từ khóa keyof
(lấy ra các tên thuộc tính của một kiểu) và infer
(trích xuất kiểu từ một điều kiện), chúng ta có thể tạo ra những utility types vô cùng linh hoạt.
Xây Dựng Custom Utility Types: Các Ví Dụ Thực Tế
Chúng ta sẽ cùng nhau tạo một vài custom utility types hữu ích để thấy được sức mạnh của chúng.
Ví dụ 1: Nullable<T>
- Biến tất cả thuộc tính thành Optional và Nullable
Đôi khi chúng ta cần một phiên bản của kiểu dữ liệu mà tất cả các thuộc tính đều có thể là null
hoặc undefined
. Utility type này sẽ giúp ích trong các trường hợp làm việc với API trả về dữ liệu có thể thiếu hoặc rỗng.
type Nullable<T> = {
[P in keyof T]: T[P] | null | undefined;
};
Giải thích code:
[P in keyof T]
: Chúng ta lặp qua tất cả các tên thuộc tínhP
trong kiểuT
.T[P]
: Lấy kiểu dữ liệu gốc của thuộc tínhP
trong kiểuT
.T[P] | null | undefined
: Tạo ra kiểu mới cho thuộc tínhP
bằng cách kết hợp kiểu gốc vớinull
vàundefined
bằng toán tử union (|
).
Cách sử dụng:
interface User {
id: number;
name: string;
email: string;
}
type PossibleUser = Nullable<User>;
/*
PossibleUser sẽ có kiểu:
{
id: number | null | undefined;
name: string | null | undefined;
email: string | null | undefined;
}
*/
let user1: User = { id: 1, name: 'Alice', email: 'a@example.com' };
// let user2: PossibleUser = { id: null, name: undefined, email: 'b@example.com' }; // Hợp lệ!
// let user3: PossibleUser = {}; // Hợp lệ vì tất cả đều là undefined
Utility type này khác với Partial<T>
ở chỗ Partial
chỉ làm cho thuộc tính là optional (prop?: Type
), còn Nullable
(như định nghĩa trên) giữ nguyên thuộc tính là bắt buộc nhưng cho phép giá trị của nó là null
hoặc undefined
. Nếu bạn muốn kết hợp cả hai, bạn có thể định nghĩa lại hoặc kết hợp: type OptionalAndNullable<T> = { [P in keyof T]?: T[P] | null };
.
Ví dụ 2: FunctionKeys<T>
- Lấy ra tên các thuộc tính là hàm
Giả sử bạn có một object và muốn biết những thuộc tính nào của nó là hàm.
type FunctionKeys<T> = {
[K in keyof T]: T[K] extends Function ? K : never;
}[keyof T];
Giải thích code:
[K in keyof T]
: Lặp qua tất cả các tên thuộc tínhK
trong kiểuT
.T[K] extends Function ? K : never
: Đây là một conditional type lồng bên trong mapped type. Nếu kiểu của thuộc tínhT[K]
extends Function
(có nghĩa là nó là một hàm), thì chúng ta giữ lại tên thuộc tínhK
. Ngược lại, chúng ta ánh xạ nó thànhnever
. Thuộc tính được ánh xạ thànhnever
sẽ bị loại bỏ khỏi kiểu kết quả của mapped type....[keyof T]
: Sau khi tạo ra một kiểu mới chỉ chứa các thuộc tính là hàm, chúng ta sử dụng[keyof ...]
một lần nữa. Đây là một kỹ thuật phổ biến để lấy ra một union type của tất cả các giá trị của thuộc tính trong kiểu kết quả trung gian. Vì các giá trị chỉ có thể là tên thuộc tính gốc (K
) hoặcnever
, kết quả cuối cùng sẽ là một union type chỉ chứa các tên thuộc tính là hàm.
Cách sử dụng:
interface MyObject {
id: number;
name: string;
greet: (message: string) => void;
calculate: (x: number, y: number) => number;
isActive: boolean;
}
type MethodNames = FunctionKeys<MyObject>; // "greet" | "calculate"
let methodName: MethodNames;
methodName = "greet"; // Hợp lệ
// methodName = "id"; // Lỗi! Kiểu 'id' không được gán cho kiểu 'MethodNames'.
Utility type này rất hữu ích khi bạn muốn làm việc đặc biệt với các method của một class hoặc object.
Ví dụ 3: UnpackArray<T>
- Trích xuất kiểu phần tử của một mảng
Đây là một ví dụ điển hình cho việc sử dụng từ khóa infer
. Chúng ta muốn lấy kiểu dữ liệu của các phần tử bên trong một mảng.
type UnpackArray<T> = T extends (infer ElementType)[] ? ElementType : T;
Giải thích code:
T extends (infer ElementType)[]
: Đây là một conditional type. Chúng ta kiểm tra xem kiểuT
có khớp với cấu trúc của một mảng hay không (...[]
). Nếu có, từ khóainfer ElementType
sẽ trích xuất kiểu dữ liệu của các phần tử bên trong mảng và gán nó vào một biến kiểu cục bộ tên làElementType
.? ElementType : T
: Nếu điều kiện đúng (T là mảng), chúng ta trả vềElementType
(kiểu phần tử). Nếu sai (T không phải là mảng), chúng ta trả về kiểu gốcT
.
Cách sử dụng:
type StringArray = string[];
type StringElementType = UnpackArray<StringArray>; // string
type NumberOrStringArray = (number | string)[];
type ElementType = UnpackArray<NumberOrStringArray>; // number | string
type NotAnArray = number;
type Result = UnpackArray<NotAnArray>; // number
UnpackArray
(hoặc thường được gọi là ElementType
trong các thư viện) cực kỳ hữu ích khi làm việc với các generic types liên quan đến mảng hoặc khi bạn không biết chính xác kiểu mảng ban đầu là gì. (Lưu ý: TypeScript 4.5+ đã có sẵn Awaited<T>
để xử lý Promise, nhưng infer
vẫn rất quan trọng cho các trường hợp khác).
Ví dụ 4: DeepReadonly<T>
- Biến một object thành Readonly sâu
Utility type Readonly<T>
có sẵn chỉ làm cho các thuộc tính ở cấp độ trên cùng trở thành readonly
. Nếu một thuộc tính là một object hoặc mảng, các phần tử bên trong nó vẫn có thể thay đổi. DeepReadonly
sẽ áp dụng tính chất readonly
một cách đệ quy.
type DeepReadonly<T> = T extends object ? {
readonly [K in keyof T]: DeepReadonly<T[K]>;
} : T;
Giải thích code:
T extends object
: Kiểm tra xem kiểuT
có phải là một object (bao gồm cả mảng, nhưng không bao gồm các kiểu nguyên thủy nhưstring
,number
,boolean
,null
,undefined
,symbol
,bigint
). Điều này ngăn chặn việc đệ quy vô hạn với các kiểu nguyên thủy.? { ... } : T
: NếuT
là object, áp dụng mapped type. Nếu không (là kiểu nguyên thủy), trả về kiểu gốcT
.readonly [K in keyof T]
: Lặp qua các thuộc tínhK
của objectT
và thêm modifierreadonly
.DeepReadonly<T[K]>
: Đây là phần đệ quy. Thay vì chỉ lấy kiểu gốcT[K]
, chúng ta gọi lạiDeepReadonly
trên kiểu của thuộc tính đó. Điều này đảm bảo rằng nếuT[K]
là một object khác, nó cũng sẽ được xử lý sâu hơn.
Cách sử dụng:
interface Settings {
theme: 'dark' | 'light';
database: {
host: string;
port: number;
};
features: string[];
}
type ImmutableSettings = DeepReadonly<Settings>;
/*
ImmutableSettings sẽ có kiểu:
{
readonly theme: 'dark' | 'light';
readonly database: {
readonly host: string;
readonly port: number;
};
readonly features: readonly string[]; // Lưu ý: Mảng cũng trở thành readonly
}
*/
declare const config: ImmutableSettings;
// config.theme = 'light'; // Lỗi! Không thể gán vì thuộc tính 'theme' là chỉ đọc.
// config.database.port = 3307; // Lỗi! Không thể gán vì thuộc tính 'port' là chỉ đọc (do đệ quy).
// config.features.push('new'); // Lỗi! Thuộc tính 'features' là chỉ đọc.
DeepReadonly
rất hữu ích trong các ứng dụng quản lý trạng thái, nơi bạn muốn đảm bảo rằng dữ liệu trạng thái không bị thay đổi trực tiếp.
Ví dụ 5: RequireOnly<T, K extends keyof T>
- Chỉ yêu cầu một số thuộc tính cụ thể
Ngược lại với Partial
(tất cả optional) hoặc Required
(tất cả required), đôi khi bạn chỉ muốn một vài thuộc tính trong một kiểu dữ liệu trở nên bắt buộc, trong khi các thuộc tính còn lại vẫn giữ nguyên tính chất optional ban đầu hoặc trở thành optional.
type RequireOnly<T, K extends keyof T> =
Partial<T> & // Làm tất cả optional trước...
Pick<T, K>; // ...sau đó làm các thuộc tính K được chọn thành bắt buộc
Giải thích code:
K extends keyof T
: Generic parameterK
bị ràng buộc chỉ có thể là một union type của các key hợp lệ củaT
.Partial<T>
: Sử dụng utility type sẵn có để tạo ra một kiểu mà tất cả các thuộc tính củaT
đều là optional.Pick<T, K>
: Sử dụng utility type sẵn có để tạo ra một kiểu mới chỉ chứa các thuộc tính được chỉ định bởiK
, và các thuộc tính này là bắt buộc (vìPick
không làm cho chúng optional).&
: Kết hợp (intersection) hai kiểu. Kết quả là một kiểu dữ liệu chứa tất cả các thuộc tính củaT
. Các thuộc tính nằm trongK
sẽ lấy tính chấtbắt buộc
từPick<T, K>
, trong khi các thuộc tính không nằm trongK
sẽ lấy tính chấtoptional
từPartial<T>
.
Cách sử dụng:
interface Product {
id: number;
name: string;
description?: string; // Optional ban đầu
price: number;
stock?: number; // Optional ban đầu
}
// Yêu cầu 'name' và 'price' phải có mặt
type ProductForCreation = RequireOnly<Product, 'name' | 'price'>;
/*
ProductForCreation sẽ có kiểu:
{
id?: number; // Optional (từ Partial)
name: string; // Required (từ Pick)
description?: string; // Optional (từ Partial, giữ nguyên)
price: number; // Required (từ Pick)
stock?: number; // Optional (từ Partial, giữ nguyên)
}
*/
let validProduct: ProductForCreation = {
name: 'Laptop',
price: 1200,
// id, description, stock là optional, có thể có hoặc không
};
// let invalidProduct: ProductForCreation = { name: 'Tablet' }; // Lỗi! Thiếu 'price'.
RequireOnly
là một ví dụ tuyệt vời về việc kết hợp các utility types sẵn có để tạo ra một utility type mới mạnh mẽ hơn, giải quyết một nhu cầu rất cụ thể thường gặp khi làm việc với các form hoặc payload API.
Tại Sao Nên Tạo Custom Utility Types?
- Tăng cường An toàn kiểu: Custom utility types giúp bạn định nghĩa chính xác các ràng buộc về kiểu dữ liệu cho các trường hợp sử dụng đặc thù, giảm thiểu lỗi runtime liên quan đến kiểu.
- Tái sử dụng Code: Thay vì viết lại logic biến đổi kiểu ở nhiều nơi, bạn chỉ cần định nghĩa utility type một lần và sử dụng lại.
- Đọc hiểu Code: Với tên gọi mang tính mô tả (như
DeepReadonly
,NullableUser
), code của bạn trở nên dễ hiểu hơn rất nhiều. - Dễ dàng Bảo trì: Khi có sự thay đổi trong cấu trúc kiểu dữ liệu gốc, bạn chỉ cần điều chỉnh utility type, thay vì sửa đổi ở mọi nơi sử dụng.
- Xử lý các Kịch bản phức tạp: Cho phép bạn thao tác và biến đổi kiểu dữ liệu theo những cách mà các utility types tích hợp không thể làm được.
Comments