TypeScript Types Nâng Cao Cho React: Union, Generics và Utility Types
Nếu bạn đã dùng TypeScript trong React được một thời gian, chắc hẳn bạn đã qua giai đoạn chỉ dùng string, number, boolean cơ bản. Nhưng để thực sự tận dụng TypeScript — không phải chỉ để code không lỗi, mà để TypeScript giúp bạn catch bug trước khi chạy — bạn cần biết những pattern nâng cao hơn.
Bài này tổng hợp những TypeScript patterns thực tế nhất khi làm việc với React: từ Union Types, Discriminated Union, đến Generics và các utility types mạnh như Parameters, ReturnType. Mỗi phần đều có ví dụ cụ thể để bạn áp dụng ngay.
#1. Union Types và Template Literal Types
Union type cho phép một giá trị thuộc về một trong nhiều type. Thay vì dùng string chung chung, bạn có thể giới hạn chính xác những giá trị hợp lệ.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ❌ Quá chung — TypeScript không thể giúp gì nhiều
type Status = string;
// ✅ Rõ ràng — TypeScript sẽ báo lỗi nếu dùng sai
type Status = "loading" | "success" | "error";
function StatusBadge({ status }: { status: Status }) {
if (status === "loading") return <Spinner />;
if (status === "success") return <CheckIcon />;
return <ErrorIcon />;
}
<StatusBadge status="loading" /> // ✅
<StatusBadge status="pending" /> // ❌ compile errorTemplate Literal Types kết hợp string literals để tạo ra tập hợp string có cấu trúc:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type Direction = "top" | "bottom" | "left" | "right";
type Padding = `padding-${Direction}`;
// → "padding-top" | "padding-bottom" | "padding-left" | "padding-right"
type Size = "sm" | "md" | "lg";
type Breakpoint = "mobile" | "tablet" | "desktop";
type ResponsiveClass = `${Breakpoint}:${Size}`;
// → "mobile:sm" | "mobile:md" | ... | "desktop:lg"
function applyPadding(side: Padding, value: string) {
document.body.style.setProperty(`--${side}`, value);
}
applyPadding("padding-top", "16px"); // ✅
applyPadding("padding-center", "16px"); // ❌ compile errorGiải thích:
- Union types giúp API của bạn tự document — nhìn vào type là biết nhận giá trị gì
- Template Literal Types tạo ra tập hợp string theo quy tắc, không cần viết từng giá trị thủ công
- Trong thực tế dùng nhiều cho: CSS class names, event names, API endpoints
#2. Discriminated Union
Discriminated union là một dạng union type, trong đó mỗi member đều có một property chung — nhưng giá trị của property đó là khác nhau (literal type). TypeScript dùng property chung này để xác định chính xác member nào đang được dùng trong từng trường hợp.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
type Action =
| { type: "INCREMENT" }
| { type: "DECREMENT" }
| { type: "SET_VALUE"; payload: number }
| { type: "RESET"; payload: { value: number; reason: string } };
function reducer(state: number, action: Action): number {
switch (action.type) {
case "INCREMENT":
return state + 1;
case "DECREMENT":
return state - 1;
case "SET_VALUE":
// TypeScript biết chắc action.payload là number tại đây
return action.payload;
case "RESET":
// TypeScript biết chắc action.payload là { value, reason }
console.log(`Reset vì: ${action.payload.reason}`);
return action.payload.value;
}
}
// TypeScript kiểm tra ngay khi dispatch
dispatch({ type: "SET_VALUE" }); // ❌ thiếu payload
dispatch({ type: "SET_VALUE", payload: 5 }); // ✅
dispatch({ type: "RESET", payload: 0 }); // ❌ payload sai kiểuDiscriminated union đặc biệt hữu ích cho async state:
1
2
3
4
5
6
7
8
9
10
11
12
type AsyncState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: string };
function UserCard({ state }: { state: AsyncState<User> }) {
if (state.status === "loading") return <Skeleton />;
if (state.status === "error") return <p>{state.error}</p>; // TypeScript biết state.error tồn tại
if (state.status === "success") return <p>{state.data.name}</p>; // TypeScript biết state.data tồn tại
return null;
}Giải thích:
- Property dùng để phân biệt (
type,status) phải là literal type — không phảistringchung chung - TypeScript tự động thu hẹp type trong mỗi
if/case— biết chính xác bạn đang xử lý member nào mà không cần ép kiểu thủ công - Tốt hơn nhiều so với dùng
data?: T; error?: stringvì không thể có cả hai cùng lúc
#3. unknown vs any vs never
Ba type đặc biệt này hay bị nhầm lẫn nhưng có công dụng hoàn toàn khác nhau.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// any — tắt TypeScript hoàn toàn
let danger: any = "hello";
danger.toFixed(2); // ✅ không lỗi, nhưng sẽ crash lúc runtime!
danger.nonExistent(); // ✅ không lỗi, nhưng sẽ crash lúc runtime!
// unknown — type-safe alternative cho any
// fetchSomething() là bất kỳ nguồn data không rõ type: response.json(), localStorage, event.data...
let safe: unknown = fetchSomething();
safe.toFixed(2); // ❌ compile error — phải validate trước
safe.nonExistent(); // ❌ compile error
// Phải kiểm tra type trước khi dùng
if (typeof safe === "number") {
safe.toFixed(2); // ✅ TypeScript đã narrow thành number
}
// never — type không thể tồn tại
function throwError(message: string): never {
throw new Error(message); // Không bao giờ return
}
// never dùng để exhaustive check — đảm bảo xử lý hết mọi case
type Shape = "circle" | "square" | "triangle";
function getArea(shape: Shape): number {
switch (shape) {
case "circle": return Math.PI * 5 ** 2;
case "square": return 25;
case "triangle": return 12;
default:
// Nếu thêm "pentagon" vào Shape nhưng quên xử lý ở đây → compile error
const exhaustiveCheck: never = shape;
throw new Error(`Unhandled shape: ${exhaustiveCheck}`);
}
}Khi nào dùng gì:
unknown— khi nhận data từ API, localStorage, user input — bất cứ thứ gì chưa biết typeany— chỉ khi migrate code cũ hoặc làm việc với thư viện không có typesnever— exhaustive checks trong switch/if, function không bao giờ return
#4. as const — Immutable Tuple và Literal Types
as const nói với TypeScript rằng một giá trị là bất biến và TypeScript nên suy ra type cụ thể nhất có thể.
1
2
3
4
5
6
// Không có as const — TypeScript suy ra type rộng
const colors = ["red", "green", "blue"];
// Type: string[] ← quá rộng
const config = { theme: "dark", size: "lg" };
// Type: { theme: string; size: string } ← mất thông tin1
2
3
4
5
6
// Có as const — TypeScript suy ra type chính xác
const colors = ["red", "green", "blue"] as const;
// Type: readonly ["red", "green", "blue"] ← tuple chính xác
const config = { theme: "dark", size: "lg" } as const;
// Type: { readonly theme: "dark"; readonly size: "lg" }Ứng dụng thực tế — derive union type từ array:
1
2
3
const ROLES = ["admin", "editor", "viewer"] as const;
type Role = typeof ROLES[number];
// Type: "admin" | "editor" | "viewer"1
2
3
// Khi thêm "moderator" vào ROLES → type Role tự động cập nhật
const ROLES = ["admin", "editor", "viewer", "moderator"] as const;
// Không cần sửa type Role!as const cũng là cách React hooks dùng bên dưới để useState trả về tuple đúng type:
1
2
3
4
5
6
7
8
// Mô phỏng cách useState hoạt động
function useState<T>(initial: T): readonly [T, (value: T) => void] {
// ...
}
// as const đảm bảo TypeScript biết chính xác index 0 là value, index 1 là setter
const [count, setCount] = useState(0);
// count: number ✅, setCount: (value: number) => void ✅#5. Generic trong TSX — Tránh Lỗi Parse
Khi viết generic component trong file .tsx, TypeScript parser có thể nhầm <T> với JSX tag mở.
1
2
3
4
5
6
7
8
9
10
11
12
13
// ❌ Parser nhầm <T> là JSX tag → syntax error
const identity = <T>(value: T): T => value;
// ✅ Cách ưu tiên: Dùng function declaration — rõ ràng, không bị parser nhầm
function identity<T>(value: T): T {
return value;
}
// Nếu bắt buộc phải dùng arrow function, thêm dấu phẩy sau generic
const identity = <T,>(value: T): T => value;
// Hoặc thêm constraint để tránh nhầm lẫn
const identity = <T extends unknown>(value: T): T => value;Generic component thực tế:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Generic list component — hoạt động với bất kỳ kiểu data nào
type ListProps<T> = {
items: T[];
renderItem: (item: T) => React.ReactNode;
keyExtractor: (item: T) => string;
};
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return (
<ul>
{items.map((item) => (
<li key={keyExtractor(item)}>{renderItem(item)}</li>
))}
</ul>
);
}
// TypeScript tự infer T từ items
<List
items={users}
renderItem={(user) => <span>{user.name}</span>} // user được infer là User
keyExtractor={(user) => user.id}
/>#6. Parameters và ReturnType — Derive Types Từ Functions
Thay vì định nghĩa type thủ công và lo bị lệch nhau, hãy derive từ function đã có.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
function createUser(name: string, age: number, role: "admin" | "user") {
return { id: crypto.randomUUID(), name, age, role, createdAt: new Date() };
}
// Derive thay vì viết lại thủ công
type CreateUserParams = Parameters<typeof createUser>;
// → [name: string, age: number, role: "admin" | "user"]
type CreateUserReturn = ReturnType<typeof createUser>;
// → { id: string; name: string; age: number; role: "admin" | "user"; createdAt: Date }
// Lấy type của từng tham số theo index
type UserName = Parameters<typeof createUser>[0]; // string
type UserRole = Parameters<typeof createUser>[2]; // "admin" | "user"Áp dụng với React hooks:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function useUserData(userId: string) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(false);
// ... fetch logic
return { user, loading, refetch: () => fetchUser(userId) };
}
// Derive type từ hook — không cần định nghĩa riêng
type UserDataState = ReturnType<typeof useUserData>;
// → { user: User | null; loading: boolean; refetch: () => void }
// Dùng khi truyền state xuống component
function UserSection({ data }: { data: UserDataState }) {
if (data.loading) return <Spinner />;
return <UserCard user={data.user} />;
}Giải thích:
Parameters<T>trả về tuple chứa type của từng tham số trong functionTReturnType<T>trả về type mà functionTtrả về- Nguyên tắc: function/hook là source of truth — type derive từ đó, không viết riêng
- Khi function thay đổi, các derived type tự cập nhật → không bao giờ out-of-sync
Nắm vững 6 patterns này sẽ giúp bạn viết TypeScript trong React ở mức độ thực sự có ích — không phải chỉ thêm type để cho có, mà để TypeScript tự động bắt lỗi cho bạn trước khi chạy. Điểm mấu chốt: càng specific type bạn định nghĩa, TypeScript càng giúp được nhiều hơn.