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ếpchí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:

  1. 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].
  2. 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ếu TypeA có thể gán được cho TypeB, 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ính P trong kiểu T.
    • T[P]: Lấy kiểu dữ liệu gốc của thuộc tính P trong kiểu T.
    • T[P] | null | undefined: Tạo ra kiểu mới cho thuộc tính P bằng cách kết hợp kiểu gốc với nullundefined 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ính K trong kiểu T.
    • 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ính T[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ính K. Ngược lại, chúng ta ánh xạ nó thành never. Thuộc tính được ánh xạ thành never 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ặc never, 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ểu Tkhớp với cấu trúc của một mảng hay không (...[]). Nếu có, từ khóa infer 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ốc T.
  • 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ểu T 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ếu T là object, áp dụng mapped type. Nếu không (là kiểu nguyên thủy), trả về kiểu gốc T.
    • readonly [K in keyof T]: Lặp qua các thuộc tính K của object T và thêm modifier readonly.
    • DeepReadonly<T[K]>: Đây là phần đệ quy. Thay vì chỉ lấy kiểu gốc T[K], chúng ta gọi lại DeepReadonly trên kiểu của thuộc tính đó. Điều này đảm bảo rằng nếu T[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 parameter K bị ràng buộc chỉ có thể là một union type của các key hợp lệ của T.
    • 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ủa T đề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ởi K, 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ủa T. Các thuộc tính nằm trong K sẽ lấy tính chất bắt buộc từ Pick<T, K>, trong khi các thuộc tính không nằm trong K sẽ lấy tính chất optional 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?

  1. 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.
  2. 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.
  3. Đọ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.
  4. 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.
  5. 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

There are no comments at the moment.