Bài 13.3: Built-in utility types trong TypeScript

Chào mừng bạn quay trở lại với chuỗi bài viết về Lập trình Web Front-end! Hôm nay, chúng ta sẽ đi sâu vào một khía cạnh cực kỳ mạnh mẽ của TypeScript giúp bạn thao tác và biến đổi các kiểu dữ liệu hiện có một cách linh hoạthiệu quả: Built-in Utility Types.

Bạn đã từng gặp tình huống cần tạo một kiểu mới chỉ bằng cách lấy một vài thuộc tính từ một kiểu lớn hơn, hoặc biến tất cả các thuộc tính của một kiểu thành tùy chọn (optional)? Trước đây, bạn có thể phải viết lại định nghĩa kiểu, nhưng với Utility Types, TypeScript cung cấp sẵn những công cụ như "dao, búa" giúp bạn làm điều đó chỉ với một dòng code.

Hãy cùng khám phá những "tiện ích" có sẵn này và xem chúng giúp công việc hàng ngày của developer chúng ta nhẹ nhàng hơnan toàn hơn như thế nào nhé!

Partial<T>: Biến Tất Cả Thuộc Tính Thành Tùy Chọn

Partial<T> là một trong những Utility Type được sử dụng phổ biến nhất. Nó nhận vào một kiểu T và trả về một kiểu mới, trong đó tất cả các thuộc tính của T đều được đánh dấu là tùy chọn (optional).

Điều này cực kỳ hữu ích khi bạn cần tạo một đối tượng chứa một phần dữ liệu của một kiểu nào đó, ví dụ như trong các hàm cập nhật (update functions) hoặc khi xử lý các cấu hình (configuration objects) mà không cần cung cấp đầy đủ tất cả các trường.

Hãy xem ví dụ:

interface User {
  id: number;
  name: string;
  email: string;
  createdAt: Date;
}

// Sử dụng Partial để tạo kiểu UserPartial
type UserPartial = Partial<User>;

/*
UserPartial sẽ có kiểu như sau:
{
  id?: number;
  name?: string;
  email?: string;
  createdAt?: Date;
}
*/

function updateUser(userId: number, updates: UserPartial) {
  // Logic cập nhật người dùng trong database
  console.log(`Đang cập nhật người dùng ID ${userId} với dữ liệu:`, updates);
  // Ví dụ: gọi API PUT /users/:userId với body là updates
}

// Bạn có thể truyền vào chỉ một hoặc vài thuộc tính
updateUser(123, { name: 'Alice' }); // Hợp lệ
updateUser(456, { email: 'bob@example.com', createdAt: new Date() }); // Cũng hợp lệ
// updateUser(789, { id: 999 }); // Hợp lệ

Trong ví dụ trên, chúng ta có interface User với 4 thuộc tính bắt buộc. Khi sử dụng Partial<User>, chúng ta nhận được kiểu UserPartial với tất cả các thuộc tính đều có dấu ? đằng sau, biểu thị chúng là tùy chọn. Điều này cho phép hàm updateUser nhận vào một đối tượng updates mà không cần phải có đủ cả 4 thuộc tính của User. Thật tiện lợi phải không nào?

Required<T>: Đảm Bảo Tất Cả Thuộc Tính Là Bắt Buộc

Ngược lại với Partial<T>, Required<T> nhận vào một kiểu T và trả về một kiểu mới mà trong đó tất cả các thuộc tính (kể cả những thuộc tính ban đầu là tùy chọn) đều trở thành bắt buộc.

Điều này hữu ích khi bạn nhận được dữ liệu mà ban đầu có thể thiếu một số trường (ví dụ: từ form nhập liệu với các trường tùy chọn), nhưng sau khi xử lý hoặc validate, bạn muốn đảm bảo rằng tất cả các trường cần thiết đều đã có mặt và có giá trị.

Xem ví dụ minh họa:

interface ProductSettings {
  id: string;
  theme?: string; // Tùy chọn
  notifications?: boolean; // Tùy chọn
  language: string; // Bắt buộc
}

// Sử dụng Required để tạo kiểu CompleteProductSettings
type CompleteProductSettings = Required<ProductSettings>;

/*
CompleteProductSettings sẽ có kiểu như sau:
{
  id: string; // Vẫn bắt buộc
  theme: string; // Bây giờ là BẮT BUỘC
  notifications: boolean; // Bây giờ là BẮT BUỘC
  language: string; // Vẫn bắt buộc
}
*/

const userPref: CompleteProductSettings = {
  id: 'user-abc',
  theme: 'dark', // Bắt buộc phải có
  notifications: true, // Bắt buộc phải có
  language: 'en' // Bắt buộc phải có
}; // Hợp lệ

// const incompletePref: CompleteProductSettings = { id: 'user-xyz', language: 'fr' };
// Lỗi TypeScript! Các thuộc tính 'theme' và 'notifications' bị thiếu.

Trong ví dụ này, ProductSettings có hai thuộc tính tùy chọn (theme, notifications). Required<ProductSettings> tạo ra CompleteProductSettings nơi cả themenotifications đều trở thành bắt buộc, giúp TypeScript kiểm tra và đảm bảo bạn cung cấp đầy đủ dữ liệu khi khai báo một biến thuộc kiểu này.

ReadOnly<T>: Tạo Phiên Bản Chỉ Đọc

ReadOnly<T> nhận vào một kiểu T và trả về một kiểu mới với tất cả các thuộc tính được đánh dấu là chỉ đọc (read-only). Điều này có nghĩa là bạn không thể gán lại giá trị cho các thuộc tính này sau khi đối tượng đã được tạo.

Đây là một công cụ tuyệt vời để đảm bảo tính bất biến (immutability) của dữ liệu. Khi bạn truyền một đối tượng ReadOnly<T> vào một hàm, bạn biết chắc rằng hàm đó không thể thay đổi trạng thái bên trong của đối tượng gốc.

Ví dụ:

interface Config {
  url: string;
  timeout: number;
}

// Sử dụng ReadOnly để tạo kiểu ImmutableConfig
type ImmutableConfig = ReadOnly<Config>;

/*
ImmutableConfig sẽ có kiểu như sau:
{
  readonly url: string;
  readonly timeout: number;
}
*/

const appConfig: ImmutableConfig = {
  url: 'https://api.example.com',
  timeout: 5000
};

console.log(appConfig.url); // Vẫn đọc được giá trị

// appConfig.timeout = 10000;
// Lỗi TypeScript! Cannot assign to 'timeout' because it is a read-only property.

ImmutableConfig được tạo ra từ Config với tất cả thuộc tính là readonly. Mọi cố gắng gán lại giá trị cho appConfig.timeout sẽ bị TypeScript báo lỗi ngay tại thời điểm biên dịch, giúp ngăn chặn những thay đổi dữ liệu không mong muốn.

Pick<T, K>: Chọn Lọc Các Thuộc Tính Cần Thiết

Pick<T, K> là một Utility Type cực kỳ mạnh mẽthường xuyên được sử dụng. Nó nhận vào hai tham số:

  1. T: Kiểu gốc mà bạn muốn chọn thuộc tính từ đó.
  2. K: Một union type (kiểu kết hợp) của các chuỗi ký tự (string literals) hoặc các loại chỉ mục khác (number, symbol) đại diện cho các tên thuộc tính mà bạn muốn chọn từ T. Các tên thuộc tính này phải tồn tại trong kiểu T.

Pick<T, K> sẽ tạo ra một kiểu mới chỉ chứa đúng các thuộc tính được liệt kê trong K từ kiểu gốc T.

Hãy xem cách nó hoạt động:

interface Product {
  id: string;
  name: string;
  price: number;
  description: string;
  category: string;
  tags: string[];
  weight: number;
}

// Sử dụng Pick để tạo kiểu ProductSummary chỉ lấy id, name, price
type ProductSummary = Pick<Product, 'id' | 'name' | 'price'>;

/*
ProductSummary sẽ có kiểu như sau:
{
  id: string;
  name: string;
  price: number;
}
*/

const displayProduct: ProductSummary = {
  id: 'prod-001',
  name: 'Wireless Mouse',
  price: 25.99
}; // Hợp lệ

// const fullProduct: ProductSummary = {
//   id: 'prod-002',
//   name: 'Mechanical Keyboard',
//   price: 89.50,
//   category: 'Electronics' // Lỗi! category không thuộc kiểu ProductSummary
// };

Ở đây, chúng ta có interface Product khá đầy đủ. Pick<Product, 'id' | 'name' | 'price'> cho phép chúng ta dễ dàng tạo ra một kiểu ProductSummary gọn gàng, chỉ chứa những thông tin cần thiết để hiển thị tóm tắt sản phẩm. Điều này giúp code của bạn rõ ràng hơntránh việc mang theo dữ liệu không cần thiết.

Omit<T, K>: Loại Bỏ Các Thuộc Tính Không Cần Thiết

Omit<T, K> là "người anh em" của Pick<T, K>, nhưng làm điều ngược lại. Nó cũng nhận vào hai tham số:

  1. T: Kiểu gốc.
  2. K: Một union type của các tên thuộc tính mà bạn muốn loại bỏ khỏi T.

Omit<T, K> sẽ tạo ra một kiểu mới chứa tất cả các thuộc tính của T trừ những thuộc tính được liệt kê trong K.

Utility này rất hữu ích khi bạn muốn sử dụng hầu hết các thuộc tính của một kiểu, nhưng cần bỏ đi một vài trường (ví dụ: các trường nhạy cảm hoặc các trường chỉ dùng nội bộ).

Xem ví dụ:

interface Employee {
  id: number;
  name: string;
  email: string;
  salary: number; // Trường nhạy cảm
  department: string;
  hireDate: Date;
}

// Sử dụng Omit để tạo kiểu PublicEmployeeInfo bằng cách bỏ đi 'salary' và 'hireDate'
type PublicEmployeeInfo = Omit<Employee, 'salary' | 'hireDate'>;

/*
PublicEmployeeInfo sẽ có kiểu như sau:
{
  id: number;
  name: string;
  email: string;
  department: string;
}
*/

const publicInfo: PublicEmployeeInfo = {
  id: 101,
  name: 'John Doe',
  email: 'john.doe@company.com',
  department: 'IT'
}; // Hợp lệ

// const sensitiveInfo: PublicEmployeeInfo = {
//   id: 102, name: 'Jane Smith', email: 'jane.smith@company.com',
//   department: 'HR', salary: 60000 // Lỗi! 'salary' không thuộc kiểu PublicEmployeeInfo
// };

Trong ví dụ này, chúng ta loại bỏ các thông tin nhạy cảm (salary) và thông tin nội bộ (hireDate) từ kiểu Employee để tạo ra kiểu PublicEmployeeInfo an toàn hơn khi hiển thị ra bên ngoài hoặc truyền đi trong mạng nội bộ.

Record<K, T>: Tạo Kiểu Đối Tượng (Dictionary/Map)

Record<K, T> là một Utility Type tuyệt vời để tạo ra một kiểu đối tượng (object type) mà các khóa có kiểu K và các giá trị có kiểu T. K thường là một union type của các chuỗi (string) hoặc các loại chỉ mục khác (number, symbol, hoặc các enum).

Utility này rất phù hợp khi bạn cần mô tả một cấu trúc dữ liệu kiểu từ điển (dictionary) hoặc bản đồ (map) với các khóa được xác định trước hoặc thuộc một tập hợp cố định.

Ví dụ:

type ColorPalette = 'primary' | 'secondary' | 'accent';

// Sử dụng Record để tạo kiểu ThemeColors
type ThemeColors = Record<ColorPalette, string>;

/*
ThemeColors sẽ có kiểu như sau:
{
  primary: string;
  secondary: string;
  accent: string;
}
*/

const myTheme: ThemeColors = {
  primary: '#007bff',
  secondary: '#6c757d',
  accent: '#dc3545'
}; // Hợp lệ

// const invalidTheme: ThemeColors = {
//   primary: '#000',
//   info: '#fff' // Lỗi! 'info' không thuộc kiểu ColorPalette
// };

Ở đây, chúng ta định nghĩa một union type ColorPalette chứa các tên màu cố định. Record<ColorPalette, string> tạo ra kiểu ThemeColors đảm bảo rằng bất kỳ đối tượng nào thuộc kiểu này phải có chính xác các khóa 'primary', 'secondary', 'accent' và giá trị của chúng phải là string.

Exclude<T, U> và Extract<T, U>: Làm Việc Với Kiểu Kết Hợp (Union Types)

Hai Utility Types này được thiết kế đặc biệt để làm việc với union types (kiểu kết hợp).

  • Exclude<T, U>: Nhận vào một union type T và một union type U. Nó trả về một union type mới bằng cách loại bỏ khỏi T tất cả các thành viên có thể gán được cho U.
  • Extract<T, U>: Ngược lại, nó trả về một union type mới bằng cách chỉ giữ lại trong T tất cả các thành viên có thể gán được cho U.

Chúng rất hữu ích khi bạn muốn tạo ra các tập hợp con từ các union type lớn hơn.

Ví dụ:

type MyComplexUnion = string | number | boolean | string[] | { name: string };

// Sử dụng Exclude để loại bỏ kiểu string và number khỏi MyComplexUnion
type NonPrimitiveTypes = Exclude<MyComplexUnion, string | number>;

/*
NonPrimitiveTypes sẽ có kiểu như sau:
boolean | string[] | { name: string };
*/

// Sử dụng Extract để chỉ giữ lại kiểu string và number từ MyComplexUnion
type PrimitiveTypes = Extract<MyComplexUnion, string | number>;

/*
PrimitiveTypes sẽ có kiểu như sau:
string | number;
*/

let value1: NonPrimitiveTypes = ['apple', 'banana']; // Hợp lệ
let value2: NonPrimitiveTypes = { name: 'Test' }; // Hợp lệ
// let value3: NonPrimitiveTypes = "Hello"; // Lỗi! string đã bị loại bỏ

let value4: PrimitiveTypes = 123; // Hợp lệ
let value5: PrimitiveTypes = "World"; // Hợp lệ
// let value6: PrimitiveTypes = true; // Lỗi! boolean đã bị loại bỏ

Ví dụ trên cho thấy cách ExcludeExtract giúp bạn tinh chỉnh các union type để tạo ra các kiểu mới chỉ chứa các thành viên mong muốn hoặc loại bỏ các thành viên không cần thiết.

NonNullable<T>: Loại Bỏ null và undefined

NonNullable<T> là một Utility Type đơn giản nhưng rất hữu ích trong các tình huống bạn cần đảm bảo một biến không bao giờ có giá trị là null hoặc undefined. Nó nhận vào một kiểu T và trả về một kiểu mới bằng cách loại bỏ nullundefined khỏi T.

Điều này thường được sử dụng sau khi bạn đã thực hiện các kiểm tra if (value !== null && value !== undefined) và muốn báo cho TypeScript biết rằng giá trị đó chắc chắn không phải là null hoặc undefined nữa.

Ví dụ:

type NullableString = string | null | undefined;

// Sử dụng NonNullable để tạo kiểu NonNullableString
type DefinitelyString = NonNullable<NullableString>;

/*
DefinitelyString sẽ có kiểu như sau:
string; // Đã loại bỏ null và undefined
*/

function processString(text: DefinitelyString) {
  // Ở đây, bạn có thể tự tin sử dụng các phương thức của string
  console.log(text.toUpperCase());
}

let myValue: NullableString = "Hello World";

if (myValue !== null && myValue !== undefined) {
    // Bên trong khối if này, kiểu của myValue thực chất là NonNullable<NullableString> (string)
    processString(myValue); // Hợp lệ
}

// processString(null); // Lỗi TypeScript! Argument of type 'null' is not assignable to parameter of type 'string'.
// processString(undefined); // Lỗi TypeScript! Argument of type 'undefined' is not assignable to parameter of type 'string'.

NonNullable<NullableString> trả về kiểu string, cho phép hàm processString nhận vào một giá trị mà không cần lo lắng về việc nó có thể là null hoặc undefined. TypeScript hiểu rằng sau khi kiểm tra if, biến myValue trong ngữ cảnh đó chắc chắn là một string.

Parameters<T> và ReturnType<T>: Phân Tích Kiểu Hàm

Hai Utility Types này giúp bạn làm việc với các kiểu hàm (function types):

  • Parameters<T>: Nhận vào một kiểu hàm T và trả về một kiểu tuple (mảng cố định) đại diện cho kiểu của các tham số của hàm đó.
  • ReturnType<T>: Nhận vào một kiểu hàm T và trả về kiểu giá trị mà hàm đó trả về.

Chúng rất hữu ích khi bạn cần lấy thông tin về "signature" của một hàm để sử dụng ở nơi khác, ví dụ như khi tạo các hàm wrapper hoặc khi làm việc với các framework/library yêu cầu các kiểu tham số hoặc kiểu trả về cụ thể.

Ví dụ:

function calculateSum(a: number, b: number): number {
  return a + b;
}

// Sử dụng Parameters để lấy kiểu tham số của hàm calculateSum
// Lưu ý: dùng typeof để lấy *kiểu* của biến hàm calculateSum
type SumParams = Parameters<typeof calculateSum>;

/*
SumParams sẽ có kiểu như sau:
[a: number, b: number] // Một tuple với tên và kiểu của từng tham số
*/

// Sử dụng ReturnType để lấy kiểu trả về của hàm calculateSum
type SumReturn = ReturnType<typeof calculateSum>;

/*
SumReturn sẽ có kiểu như sau:
number; // Kiểu giá trị trả về
*/

type AsyncOperation = (data: string) => Promise<boolean>;

type AsyncParams = Parameters<AsyncOperation>; // [data: string]
type AsyncReturn = ReturnType<AsyncOperation>; // Promise<boolean>
// Lưu ý: ReturnType<T> chỉ lấy kiểu Promise<...> chứ không tự động "await" nó.

Ví dụ trên cho thấy cách chúng ta có thể "phân tích" kiểu của hàm calculateSum để lấy ra kiểu của các tham số ([number, number]) và kiểu trả về (number). Điều này rất hữu ích khi bạn cần tạo các kiểu dữ liệu động dựa trên signature của các hàm hiện có.

Awaited<T>: Nhận Kiểu Sau Khi Promise Được Giải Quyết

Awaited<T> là một Utility Type mới hơn (từ TypeScript 4.5) và cực kỳ hữu ích khi làm việc với Promise. Nó nhận vào một kiểu T (thường là một kiểu Promise) và trả về kiểu giá trị mà Promise đó sẽ giải quyết (resolve) thành.

Khi bạn sử dụng await trong code, giá trị bạn nhận được chính là kiểu đã được "awaited". Awaited<T> giúp bạn mô tả chính xác kiểu này trong hệ thống kiểu của TypeScript.

Ví dụ:

async function fetchData(id: number): Promise<{ data: any, status: number }> {
  // Giả lập gọi API và trả về Promise
  console.log(`Fetching data for ID: ${id}`);
  return { data: { id: id, name: `Item ${id}` }, status: 200 };
}

// ReturnType<typeof fetchData> sẽ là: Promise<{ data: any, status: number }>

// Sử dụng Awaited để lấy kiểu giá trị mà Promise trả về sau khi resolve
type FetchedDataType = Awaited<ReturnType<typeof fetchData>>;

/*
FetchedDataType sẽ có kiểu như sau:
{ data: any, status: number }; // Kiểu dữ liệu bên trong Promise
*/

async function processData(itemId: number) {
  const result: FetchedDataType = await fetchData(itemId);
  console.log('Data received:', result.data); // An toàn, biết chắc result có thuộc tính data
  console.log('Status:', result.status);   // An toàn, biết chắc result có thuộc tính status
}

processData(1);

Trong ví dụ này, fetchData trả về một Promise chứa một đối tượng. ReturnType<typeof fetchData> cho chúng ta kiểu Promise<{ data: any, status: number }>. Áp dụng Awaited lên kiểu đó (Awaited<ReturnType<typeof fetchData>>) sẽ cho chúng ta kiểu { data: any, status: number }, đây chính xác là kiểu của biến result sau khi await được thực thi. Điều này giúp code sử dụng async/await trở nên an toàndễ hiểu hơn về mặt kiểu dữ liệu.

Comments

There are no comments at the moment.