Bài 14.5: Bài tập thực hành advanced TypeScript

Chào mừng trở lại series blog Lập trình Web Front-end của FullhouseDev! Chúng ta đã cùng nhau khám phá rất nhiều khái niệm mạnh mẽ trong TypeScript, từ cơ bản đến nâng cao. Giờ là lúc để _xắn tay áo_ và áp dụng những kiến thức đó vào thực tế thông qua các bài tập. Thực hành là cách tốt nhất để củng cố sự hiểu biết và làm quen với việc sử dụng các tính năng _advanced_ của TypeScript một cách hiệu quả.

Trong bài viết này, chúng ta sẽ tập trung vào các bài tập vận dụng Generics, Mapped Types, Conditional Types, Template Literal Types và các kỹ thuật _typings_ phức tạp khác. Hãy chuẩn bị trình soạn thảo code của bạn và bắt đầu nào!


Bài tập 1: Xây dựng Utility Type DeepReadonly

TypeScript cung cấp utility type Readonly<T> để tạo một kiểu mới với tất cả các thuộc tính của Treadonly. Tuy nhiên, Readonly chỉ hoạt động ở cấp độ _bề mặt_ (shallow). Nếu một thuộc tính là một object hoặc array, các phần tử bên trong object/array đó vẫn có thể bị thay đổi.

Yêu cầu: Hãy tạo một utility type mới gọi là DeepReadonly<T> hoạt động _sâu_ (deeply), nghĩa là nó áp dụng readonly cho cả các thuộc tính lồng nhau (nested properties) và các phần tử của mảng bên trong.

Gợi ý: Sử dụng Mapped TypesConditional Types một cách _đệ quy_ (recursive).

Code giải pháp:

// Định nghĩa kiểu DeepReadonly
type DeepReadonly<T> = T extends (infer R)[] // Nếu T là một mảng...
  ? DeepReadonlyArray<R> // ...thì xử lý như mảng DeepReadonly
  : T extends object // Ngược lại, nếu T là một object...
    ? DeepReadonlyObject<T> // ...thì xử lý như object DeepReadonly
    : T; // Còn lại, giữ nguyên kiểu gốc (kiểu nguyên thủy)

// Helper type cho mảng DeepReadonly
// Sử dụng interface để có thể extends ReadonlyArray
interface DeepReadonlyArray<T> extends ReadonlyArray<DeepReadonly<T>> {}

// Helper type cho object DeepReadonly
// Sử dụng Mapped Type để áp dụng readonly cho từng thuộc tính
type DeepReadonlyObject<T> = {
  readonly [P in keyof T]: DeepReadonly<T[P]>; // Áp dụng DeepReadonly cho từng thuộc tính P
};

// Ví dụ minh họa
interface Person {
  name: string;
  age: number;
  address: {
    street: string;
    city: string;
  };
  tags: string[];
  isActive?: boolean;
}

type ReadonlyPerson = DeepReadonly<Person>;

// Thử truy cập và gán giá trị (sẽ báo lỗi compile-time)
declare const readonlyPerson: ReadonlyPerson;

// readonlyPerson.name = "Jane"; // Lỗi: Cannot assign to 'name' because it is a read-only property.
// readonlyPerson.address.city = "New York"; // Lỗi: Cannot assign to 'city' because it is a read-only property.
// readonlyPerson.tags.push("new tag"); // Lỗi: Property 'push' does not exist on type 'readonly string[]'.

Giải thích:

  • Chúng ta sử dụng Conditional Types (T extends ... ? ... : ...) để phân biệt giữa kiểu mảng, kiểu object và các kiểu dữ liệu nguyên thủy khác.
  • Nếu T là mảng (T extends (infer R)[]), chúng ta dùng infer R để lấy kiểu phần tử của mảng, sau đó gọi đệ quy DeepReadonly<R> cho từng phần tử và định nghĩa DeepReadonlyArray dựa trên ReadonlyArray.
  • Nếu T là object (T extends object), chúng ta dùng Mapped Types ({ [P in keyof T]: ... }) để lặp qua từng thuộc tính P của T. Đối với giá trị của mỗi thuộc tính T[P], chúng ta lại gọi đệ quy DeepReadonly<T[P]>. Từ khóa readonly được thêm vào trước tên thuộc tính để làm cho nó không thể gán lại.
  • Nếu T không phải mảng hay object (ví dụ: string, number, boolean), kiểu đó được giữ nguyên.
  • Việc kết hợp Conditional TypesMapped Types cùng với gọi lại chính kiểu DeepReadonly bên trong cho phép xử lý cấu trúc dữ liệu lồng nhau một cách hiệu quả ở mức kiểu.

Bài tập 2: Trích xuất Kiểu của Phần tử Mảng (Element Type)

Trong nhiều trường hợp, chúng ta có một kiểu mảng (ví dụ: string[]) và muốn lấy kiểu của _từng_ phần tử bên trong mảng đó (ví dụ: string). TypeScript có utility type Awaited<T> có thể "bóc" kiểu ra khỏi Promise hoặc các kiểu "bao bọc" khác. Chúng ta có thể sử dụng kỹ thuật tương tự.

Yêu cầu: Tạo một utility type ElementType<T> nhận vào một kiểu mảng T và trả về kiểu của các phần tử bên trong mảng đó. Nếu T không phải mảng, trả về never hoặc kiểu mặc định nào đó (ở đây ta sẽ trả về never).

Gợi ý: Sử dụng Conditional Types kết hợp với từ khóa infer.

Code giải pháp:

// Định nghĩa kiểu ElementType
type ElementType<T> = T extends (infer U)[] // Nếu T là một mảng và các phần tử có kiểu U...
  ? U // ...trả về kiểu U
  : never; // Ngược lại (không phải mảng), trả về never

// Ví dụ minh họa
type StringArray = string[];
type NumberArray = Array<number>;
type BooleanArray = readonly boolean[]; // Hoạt động với readonly array

type StringElementType = ElementType<StringArray>; // string
type NumberElementType = ElementType<NumberArray>; // number
type BooleanElementType = ElementType<BooleanArray>; // boolean
type NotAnArrayElementType = ElementType<number>; // never
type UnionArrayElementType = ElementType<(string | number)[]>; // string | number

// Kiểm tra
// type Test1 = StringElementType; // string
// type Test2 = NumberElementType; // number
// type Test3 = BooleanElementType; // boolean
// type Test4 = NotAnArrayElementType; // never
// type Test5 = UnionArrayElementType; // string | number

Giải thích:

  • Kiểu ElementType<T> sử dụng Conditional Type.
  • Điều kiện T extends (infer U)[] kiểm tra xem kiểu T có "mở rộng" từ một mảng hay không. Nếu có, infer U sẽ tự động suy luận và "bắt lấy" kiểu của các phần tử bên trong mảng đó, gán vào biến kiểu U.
  • Nếu điều kiện đúng, kiểu trả về là U (kiểu phần tử).
  • Nếu điều kiện sai (ví dụ: Tnumber), kiểu trả về là never, biểu thị rằng không có kiểu phần tử hợp lệ.

Bài tập 3: Tạo Object Key Bằng Template Literal Types

Template Literal Types cho phép tạo ra các kiểu chuỗi mới dựa trên các kiểu chuỗi khác, tương tự như template literals trong JavaScript. Điều này rất hữu ích khi làm việc với các API hoặc cấu trúc dữ liệu có quy ước đặt tên nhất quán.

Yêu cầu: Tạo một utility type EventHandlers<T> nhận vào một kiểu object T và tạo ra một kiểu object mới. Các khóa của object mới sẽ là các khóa của T được _tiền tố_ (prefix) bằng chuỗi 'on' và có chữ cái đầu tiên của khóa gốc được _viết hoa_. Các giá trị của object mới sẽ là một hàm không nhận tham số và trả về void.

Ví dụ: { click: string, hover: number } -> { onClick: () => void, onHover: () => void }

Gợi ý: Sử dụng Mapped Types kết hợp với Template Literal Types và utility types Capitalize<S>.

Code giải pháp:

// Định nghĩa kiểu EventHandlers
type EventHandlers<T> = {
  // Lặp qua từng khóa K trong kiểu T
  [K in keyof T as `on${Capitalize<string & K>}`]: () => void; // Sử dụng Template Literal Type và Capitalize
};

// Ví dụ minh họa
interface DOMEvents {
  click: MouseEvent;
  mouseover: MouseEvent;
  keydown: KeyboardEvent;
}

type DOMEventHandlers = EventHandlers<DOMEvents>;

/*
DOMEventHandlers sẽ là:
{
    onClick: () => void;
    onMouseover: () => void;
    onKeydown: () => void;
}
*/

// Kiểm tra
declare const handlers: DOMEventHandlers;
handlers.onClick(); // OK
// handlers.onCLick(); // Lỗi chính tả, sẽ báo lỗi
// handlers.click(); // Lỗi, key 'click' không tồn tại

Giải thích:

  • Chúng ta sử dụng Mapped Types ([K in keyof T]: ...) để lặp qua các khóa K của kiểu input T.
  • Phần as \on\${Capitalize<string & K>}`` là Key Remapping sử dụng Template Literal Type. Nó định nghĩa khóa mới:
    • Bắt đầu bằng chuỗi cố định 'on'.
    • Tiếp theo là khóa gốc K sau khi được áp dụng Capitalize.
    • Capitalize<S> là một utility type có sẵn trong TypeScript 4.1+ giúp viết hoa chữ cái đầu tiên của chuỗi literal S.
    • string & K là một thủ thuật nhỏ để đảm bảo rằng kiểu của K (có thể là string | number | symbol) chỉ còn lại phần string, vì Template Literal Types chỉ làm việc với kiểu chuỗi.
  • Giá trị của mỗi khóa mới được đặt là () => void, một kiểu hàm không nhận tham số và không trả về giá trị.

Bài tập 4: Tạo Loại Object Mới Từ Union Kiểu Literal

Đôi khi, chúng ta có một union các kiểu string literal (ví dụ: 'success' | 'error' | 'loading') và muốn tạo một kiểu object mà mỗi khóa của nó tương ứng với một literal trong union đó, và giá trị có một cấu trúc nhất định.

Yêu cầu: Tạo một utility type StatusFlags<U> nhận vào một union các string literal U và tạo một kiểu object. Mỗi khóa của object mới là một string literal từ union U, và giá trị của khóa đó là một boolean.

Ví dụ: 'success' | 'error' -> { success: boolean, error: boolean }

Gợi ý: Sử dụng Mapped Types trực tiếp trên union kiểu literal.

Code giải pháp:

// Định nghĩa kiểu StatusFlags
type StatusFlags<U extends string> = {
  [K in U]: boolean; // Lặp qua từng literal K trong union U
};

// Ví dụ minh họa
type RequestStatus = 'idle' | 'loading' | 'success' | 'error';

type RequestStatusFlags = StatusFlags<RequestStatus>;

/*
RequestStatusFlags sẽ là:
{
    idle: boolean;
    loading: boolean;
    success: boolean;
    error: boolean;
}
*/

// Kiểm tra
declare const flags: RequestStatusFlags;
flags.success = true; // OK
// flags.completed = false; // Lỗi: Property 'completed' does not exist on type 'RequestStatusFlags'.

Giải thích:

  • Mapped Types không chỉ làm việc với keyof T mà còn có thể lặp qua các thành viên của một union kiểu string literal hoặc number literal.
  • Cú pháp [K in U] nghĩa là "đối với mỗi thành viên K trong union U".
  • TypeScript sẽ tạo ra một thuộc tính mới cho mỗi thành viên của union U, và tên thuộc tính chính là thành viên đó.
  • Kiểu của thuộc tính được đặt là boolean theo yêu cầu.
  • Constraint U extends string được thêm vào để đảm bảo kiểu đầu vào U thực sự là một union các kiểu string.

Bài tập 5: Lọc Thuộc Tính Dựa Trên Kiểu Giá Trị

Chúng ta đã biết cách sử dụng Pick<T, K>Omit<T, K> để chọn hoặc loại bỏ các thuộc tính dựa trên tên khóa K. Nhưng làm thế nào nếu bạn muốn chọn các thuộc tính dựa trên kiểu _giá trị_ của chúng? Ví dụ: chỉ giữ lại các thuộc tính có giá trị là string?

Yêu cầu: Tạo một utility type PickByValueType<T, ValueType> nhận vào một kiểu object T và một kiểu ValueType. Nó sẽ tạo ra một kiểu object mới chỉ bao gồm các thuộc tính từ T mà giá trị của chúng tương thích (assignable) với ValueType.

Gợi ý: Sử dụng Mapped Types với Key Remapping (as) và Conditional Types (T[P] extends ValueType ? ... : ...).

Code giải pháp:

// Định nghĩa kiểu PickByValueType
type PickByValueType<T, ValueType> = {
  // Lặp qua từng khóa P trong kiểu T
  [P in keyof T as T[P] extends ValueType ? P : never]: T[P]; // Sử dụng Key Remapping và Conditional Type
};

// Ví dụ minh họa
interface UserProfile {
  id: number;
  username: string;
  isAdmin: boolean;
  email: string;
  createdAt: Date;
  preferences: object;
  description: string | null;
}

// Lấy chỉ các thuộc tính có giá trị là string
type StringProperties = PickByValueType<UserProfile, string>;

/*
StringProperties sẽ là:
{
    username: string;
    email: string;
}
*/

// Lấy các thuộc tính có giá trị là boolean hoặc number
type BooleanOrNumberProperties = PickByValueType<UserProfile, boolean | number>;

/*
BooleanOrNumberProperties sẽ là:
{
    id: number;
    isAdmin: boolean;
}
*/

// Kiểm tra
declare const stringProps: StringProperties;
console.log(stringProps.username); // OK
// console.log(stringProps.id); // Lỗi: Property 'id' does not exist on type 'StringProperties'.

Giải thích:

  • Chúng ta sử dụng Mapped Types ([P in keyof T]: ...) để lặp qua các khóa P của kiểu input T.
  • Phần as T[P] extends ValueType ? P : neverKey Remapping.
    • Nó kiểm tra bằng Conditional Type xem kiểu giá trị của thuộc tính hiện tại T[P] có "mở rộng" (tương thích) với ValueType hay không.
    • Nếu đúng (T[P] extends ValueType là true), khóa mới sẽ là chính khóa gốc P.
    • Nếu sai (T[P] extends ValueType là false), khóa mới sẽ là never. TypeScript có một tính năng đặc biệt: khi bạn tạo một thuộc tính với khóa là kiểu never trong Mapped Types, thuộc tính đó sẽ bị _loại bỏ_ khỏi kiểu kết quả.
  • Giá trị của thuộc tính mới được giữ nguyên là T[P].

Bài tập 6: Áp dụng Template Literal Types để Validate String Literals

Template Literal Types không chỉ để tạo ra các kiểu chuỗi mới mà còn có thể dùng để kiểm tra cấu trúc của chuỗi literal.

Yêu cầu: Tạo một kiểu ApiEndpoint<Method extends string, Path extends string> nhận vào phương thức HTTP (GET, POST, ...) và đường dẫn (/users, /products/:id, ...) và tạo ra một kiểu chuỗi literal biểu diễn endpoint hoàn chỉnh (ví dụ: 'GET /users'). Sau đó, tạo một hàm fetchData chỉ chấp nhận các endpoint có kiểu này.

Gợi ý: Sử dụng Template Literal Types để định nghĩa kiểu chuỗi kết hợp.

Code giải pháp:

// Định nghĩa kiểu ApiEndpoint
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD';

type ApiEndpoint<Method extends HttpMethod, Path extends string> =
  `${Method} ${Path}`; // Sử dụng Template Literal Type

// Ví dụ minh họa các endpoint hợp lệ
type GetUsersEndpoint = ApiEndpoint<'GET', '/users'>; // 'GET /users'
type PostProductEndpoint = ApiEndpoint<'POST', '/products'>; // 'POST /products'
type GetProductByIdEndpoint = ApiEndpoint<'GET', '/products/:id'>; // 'GET /products/:id'

// Định nghĩa hàm fetchData chỉ nhận các endpoint có kiểu ApiEndpoint
function fetchData(endpoint: ApiEndpoint<HttpMethod, string>): void {
  console.log(`Fetching data from: ${endpoint}`);
  // Logic fetch dữ liệu thực tế
}

// Sử dụng hàm với các endpoint hợp lệ
fetchData('GET /users'); // OK
fetchData('POST /products'); // OK
fetchData('GET /products/:id'); // OK

// Sử dụng hàm với các chuỗi không đúng định dạng (sẽ báo lỗi compile-time)
// fetchData('GET /users/'); // OK (chỉ cần là string khớp với mẫu)
// fetchData('FETCH /data'); // Lỗi: Type '"FETCH /data"' is not assignable to type 'ApiEndpoint<HttpMethod, string>'.
// fetchData('GET /data/extra'); // OK
// fetchData('GETDATA/users'); // Lỗi: Type '"GETDATA/users"' is not assignable to type 'ApiEndpoint<HttpMethod, string>'.

Giải thích:

  • Kiểu ApiEndpoint<Method, Path> sử dụng Template Literal Type ${Method} ${Path}. Điều này đơn giản là kết hợp giá trị kiểu literal của MethodPath với một khoảng trắng ở giữa.
  • Constraint Method extends HttpMethod giúp giới hạn các phương thức HTTP được chấp nhận, đảm bảo tính nhất quán.
  • Khi bạn sử dụng kiểu ApiEndpoint<HttpMethod, string> làm kiểu tham số cho hàm fetchData, TypeScript sẽ yêu cầu đối số phải là một chuỗi literal có dạng <một_phương_thức_trong_HttpMethod> <bất_kỳ_chuỗi_nào>. Điều này giúp bắt lỗi chính tả hoặc định dạng sai ngay tại thời điểm compile.

Comments

There are no comments at the moment.