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

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 T
là readonly
. 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 Types và Conditional 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ùnginfer R
để lấy kiểu phần tử của mảng, sau đó gọi đệ quyDeepReadonly<R>
cho từng phần tử và định nghĩaDeepReadonlyArray
dựa trênReadonlyArray
. - 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ínhP
củaT
. Đối với giá trị của mỗi thuộc tínhT[P]
, chúng ta lại gọi đệ quyDeepReadonly<T[P]>
. Từ khóareadonly
đượ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 Types và Mapped 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ểuT
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ểuU
. - 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ụ:
T
lànumber
), 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óaK
của kiểu inputT
. - 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ụngCapitalize
. 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 literalS
.string & K
là một thủ thuật nhỏ để đảm bảo rằng kiểu củaK
(có thể làstring | number | symbol
) chỉ còn lại phầnstring
, vì Template Literal Types chỉ làm việc với kiểu chuỗi.
- Bắt đầu bằng chuỗi cố định
- 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ênK
trong unionU
". - 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àoU
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>
và 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óaP
của kiểu inputT
. - Phần
as T[P] extends ValueType ? P : never
là Key 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ớiValueType
hay không. - Nếu đúng (
T[P] extends ValueType
là true), khóa mới sẽ là chính khóa gốcP
. - 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ểunever
trong Mapped Types, thuộc tính đó sẽ bị _loại bỏ_ khỏi kiểu kết quả.
- Nó kiểm tra bằng Conditional Type xem kiểu giá trị của thuộc tính hiện tại
- 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ủaMethod
vàPath
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àmfetchData
, 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