Home / TypeScript / TypeScript Types Nâng Cao Cho React: Union, Generics và Utility Types
TypeScript

TypeScript Types Nâng Cao Cho React: Union, Generics và Utility Types

Học TypeScript nâng cao cho React: Union, Discriminated Union, unknown/any/never, as const, Generics thực tế dễ hiểu

Bạn khai báo status: string. Ba tuần sau, một đồng nghiệp truyền vào "sucess" — thiếu một chữ c. TypeScript im lặng, component render nhánh sai, và bạn mất nửa buổi debug. Lỗi không nằm ở TypeScript: bạn đã bảo nó rằng mọi string đều hợp lệ, nên nó tin bạn.

Bài này đi qua những pattern giúp TypeScript bắt lỗi giùm bạn ngay lúc gõ code: Union và Template Literal Types, Discriminated Union, Generics, cùng các utility type như ParametersReturnType. Mỗi phần đều có ví dụ React dùng được 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ì string chung chung, bạn liệt kê chính xác những giá trị hợp lệ - và TypeScript chặn mọi thứ nằm ngoài danh sách đó.

1
2
// ❌ Quá chung - "sucess" cũng là string hợp lệ, TypeScript không cản
type Status = string;
1
2
3
4
5
6
7
8
9
10
11
12
13
// ✅ Rõ ràng - chỉ 3 giá trị này được chấp nhận
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="sucess" />;
// ❌ Type '"sucess"' is not assignable to type 'Status'.
//    Did you mean '"success"'?

Chú ý dòng cuối: đây đúng là con bug ở đầu bài. TypeScript không chỉ chặn nó ngay trong editor, mà còn đoán ra bạn định gõ gì và gợi ý luôn "success".

Template Literal Types ghép string literal lại để sinh ra một tập giá trị có cấu trúc, thay vì bạn phải gõ tay từng cái:

1
2
3
4
5
6
7
8
9
10
type Direction = "top" | "bottom" | "left" | "right";
type PaddingVar = `--padding-${Direction}`;
// → "--padding-top" | "--padding-bottom" | "--padding-left" | "--padding-right"

function setPaddingVar(name: PaddingVar, value: string) {
  document.body.style.setProperty(name, value);
}

setPaddingVar("--padding-top", "16px"); // ✅
setPaddingVar("--padding-center", "16px"); // ❌ compile error

Sức mạnh thật sự lộ ra khi bạn ghép hai union - TypeScript tự nhân tổ hợp cho bạn:

1
2
3
4
5
type Size = "sm" | "md" | "lg";
type Breakpoint = "mobile" | "tablet" | "desktop";

type ResponsiveClass = `${Breakpoint}:${Size}`;
// → 9 giá trị: "mobile:sm" | "mobile:md" | ... | "desktop:lg"

Ba breakpoint nhân ba size ra 9 class - tự viết tay thì mệt và dễ sót. Thêm một breakpoint vào Breakpoint, cả 3 class mới xuất hiện ngay, không phải sửa chỗ nào khác.

Giải thích:

  • Union types giúp API tự document: nhìn vào type là biết nhận giá trị gì, không cần đọc code hay comment
  • Template Literal Types sinh tập string theo quy tắc, nên khi quy tắc đổi, type đổi theo - đây là thứ union viết tay không làm được
  • Trong thực tế dùng nhiều cho: CSS custom property, class name của design system, tên event, đường dẫn API

Lưu ý

Đừng lạm dụng cho tập giá trị quá lớn. `${number}px` là hợp lệ, nhưng ghép ba union mỗi cái 20 phần tử là 8.000 tổ hợp - TypeScript sẽ chậm hẳn, và có giới hạn cứng khoảng 100.000 phần tử cho một union.

2. Discriminated Union

Hãy bắt đầu từ cách viết state mà gần như ai cũng từng viết:

1
2
3
4
5
interface UserState {
  isLoading: boolean;
  data?: User;
  error?: string;
}

Nhìn thì hợp lý, nhưng type này cho phép những state không bao giờ nên tồn tại: isLoading: true mà lại có cả data lẫn error? Vừa loading vừa lỗi? Ba field độc lập nghĩa là 2 × 2 × 2 = 8 tổ hợp, trong khi thực tế bạn chỉ có 4 trạng thái hợp lệ. Bốn tổ hợp còn lại là bug đang chờ xảy ra - và TypeScript không cản, vì bạn đã khai báo rằng chúng hợp lệ.

Discriminated union đóng cánh cửa đó lại. Mỗi member có chung một property (gọi là discriminant), nhưng giá trị của nó là literal khác nhau - TypeScript dựa vào đó để biết chính xác bạn đang ở nhánh nào:

1
2
3
4
5
type UserState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: User }
  | { status: "error"; error: string };

Giờ chỉ còn đúng 4 state, và mỗi state chỉ mang theo dữ liệu mà nó thực sự có. Không có data khi đang loading, vì data không tồn tại trong nhánh đó.

1
2
3
4
5
6
function UserCard({ state }: { state: UserState }) {
  if (state.status === "loading") return <Skeleton />;
  if (state.status === "error") return <p>{state.error}</p>; // state.error: string
  if (state.status === "success") return <p>{state.data.name}</p>; // state.data: User
  return null; // còn lại: "idle"
}

Điểm hay nằm ở chỗ TypeScript tự thu hẹp (narrow) type sau mỗi lần check status. Bạn không cần as, không cần !, không cần optional chaining. Và nếu thử đọc state.data trong nhánh "loading", bạn nhận compile error ngay - thứ mà version data?: User không bao giờ báo, nó chỉ trả về undefined rồi để app crash ở state.data.name.

2.1. Discriminated union cho reducer

Cùng ý tưởng đó áp cho action của useReducer. Trước hết là reducer — mỗi case chỉ nhìn thấy đúng payload của action đó:

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
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":
      return action.payload; // TypeScript biết payload là number
    case "RESET":
      console.log(`Reset vì: ${action.payload.reason}`); // biết payload là object
      return action.payload.value;
  }
}

// Chạy thử reducer với từng loại action
const actions: Action[] = [
  { type: "INCREMENT" },
  { type: "INCREMENT" },
  { type: "SET_VALUE", payload: 5 },
  { type: "DECREMENT" },
  { type: "RESET", payload: { value: 0, reason: "user clicked reset" } },
];

let state = 0;
for (const action of actions) {
  state = reducer(state, action);
  console.log(action.type, "→", state);
}

Bấm Run để thấy narrowing chạy thật: cùng một tên payload, nhưng ở nhánh SET_VALUE nó là number nên gán thẳng vào state, còn ở nhánh RESET nó là object nên đọc được payload.reasonpayload.value.

Ở phía component, chính union đó type-check luôn từng lời gọi dispatch. Các dòng // ❌ dưới đây không qua được type check, nên block này chỉ để đọc:

1
2
3
4
5
6
7
8
9
10
11
12
13
import { useReducer } from "react";
// Action và reducer lấy từ block trên

function Counter() {
  const [count, dispatch] = useReducer(reducer, 0);

  dispatch({ type: "INCREMENT" }); // ✅
  dispatch({ type: "SET_VALUE", payload: 5 }); // ✅
  dispatch({ type: "SET_VALUE" }); // ❌ thiếu payload
  dispatch({ type: "RESET", payload: 0 }); // ❌ payload phải là { value, reason }

  return <span>{count}</span>;
}

Mỗi action mang đúng payload của riêng nó: INCREMENT không cần payload, SET_VALUE bắt buộc có number, RESET bắt buộc có object. Một type { type: string; payload?: any } sẽ nuốt trọn cả ba lỗi trên.

Lưu ý: cái bẫy khi tách action ra biến

Đây là lỗi khiến nhiều người bối rối. Truyền object thẳng vào dispatch thì chạy tốt, nhưng gán ra biến trước lại lỗi:

1
2
3
4
5
const action = { type: "INCREMENT" };
dispatch(action);
// ❌ Argument of type '{ type: string; }' is not assignable to parameter of type 'Action'.
//    Property 'payload' is missing in type '{ type: string; }'
//    but required in type '{ type: "RESET"; payload: { value: number; reason: string; }; }'

Thông báo lỗi này khá đánh lạc hướng: nó than phiền thiếu payload của RESET, trong khi bạn đâu có định dispatch RESET. Nguyên nhân thật nằm ở đoạn { type: string; }. TypeScript suy ra action có type { type: string } - vì action.type là property có thể gán lại, nó mở rộng literal "INCREMENT" thành string. Mà string thì không khớp với discriminant literal của bất kỳ member nào, nên TypeScript thử lần lượt từng member rồi báo lỗi ở member cuối cùng.

Khi thấy lỗi nhắc tới một action bạn không hề dùng, hãy nghĩ ngay tới widening. Cách sửa là dùng as const (xem phần 4) hoặc chú thích type rõ ràng:

1
2
const action = { type: "INCREMENT" } as const; // ✅ type: "INCREMENT"
const action2: Action = { type: "INCREMENT" }; // ✅ cũng được

Tóm lại, 3 điều cần nhớ:

  • Discriminant phải là literal type. status: string thì TypeScript không narrow được gì - nó chỉ narrow khi mỗi nhánh có một giá trị literal riêng để phân biệt
  • Đừng phòng state sai, hãy làm cho nó không tồn tại. Nguyên tắc “make illegal states unrepresentable”: thay vì viết if (isLoading && error) để phòng trường hợp vô lý, hãy thiết kế type sao cho trường hợp đó không viết ra được. Optional field (data?, error?) là dấu hiệu bạn đang gộp nhiều state vào một type
  • Gặp lỗi lạ, nghĩ tới widening. Khi TypeScript than phiền về một action bạn không dùng, gần như chắc chắn một literal đã bị mở rộng thành string. as const sửa được

Ở phần 3 ta sẽ dùng never để TypeScript bắt buộc bạn xử lý hết mọi nhánh - thêm một member vào union mà quên handle, compile error ngay.

3. unknown vs any vs never

Ba type này hay bị gộp chung vào nhóm “type kỳ lạ”, nhưng chúng nằm ở hai đầu đối lập nhau:

  • unknowntop type - chứa được mọi giá trị
  • neverbottom type - không chứa được giá trị nào
  • any thì không nằm đâu cả: nó tắt hệ thống type

3.1. any - cánh cửa thoát hiểm bạn không nên mở

1
2
3
4
5
const danger: any = "hello";

danger.toFixed(2); // ✅ TypeScript im lặng... nhưng runtime crash
danger.nonExistent(); // ✅ TypeScript im lặng... nhưng runtime crash
// TypeError: danger.toFixed is not a function

any không phải “type bất kỳ” - nó là lời hứa “tôi biết mình đang làm gì, đừng kiểm tra nữa”. Và TypeScript nghe lời: "hello".toFixed(2) lẽ ra phải bị chặn ngay, nhưng nó lọt thẳng ra runtime. Mỗi any bạn viết là một lỗ hổng trong lưới an toàn, và lỗ hổng đó lan ra: gán any cho biến khác thì biến đó cũng mất kiểm tra.

3.2. unknown - “chưa biết” chứ không phải “khỏi cần biết”

unknown cũng nhận mọi giá trị như any, nhưng bắt bạn chứng minh type trước khi động vào:

1
2
3
4
const raw: unknown = JSON.parse(localStorage.getItem("user") ?? "{}");

raw.name; // ❌ 'raw' is of type 'unknown'
raw.toFixed(2); // ❌ 'raw' is of type 'unknown'

Hai dòng trên không qua được type check — tsc báo error TS18046: 'raw' is of type 'unknown'. Phải narrow trước, sau đó TypeScript mới cho phép động vào raw:

1
2
3
4
5
6
7
8
// Giả lập dữ liệu đã lưu từ trước
localStorage.setItem("user", JSON.stringify({ name: "Minh" }));

const raw: unknown = JSON.parse(localStorage.getItem("user") ?? "{}");

if (typeof raw === "object" && raw !== null && "name" in raw) {
  console.log(raw.name); // ✅ TypeScript đã cho phép
}

Đây chính là điều bạn muốn ở ranh giới của app - nơi dữ liệu từ ngoài chảy vào: response.json(), localStorage, postMessage, query param. Những chỗ đó bạn thật sự chưa biết type, và unknown ép bạn kiểm tra thay vì cầu nguyện.

Một chỗ dùng unknown mà React dev gặp hằng ngày là catch:

1
2
3
4
5
6
7
8
9
10
try {
  await saveUser(user);
} catch (error) {
  // error là unknown - vì JS cho phép `throw` bất cứ thứ gì, kể cả string
  if (error instanceof Error) {
    toast(error.message); // ✅
  } else {
    toast("Đã có lỗi xảy ra");
  }
}

Từ TypeScript 4.4, cờ useUnknownInCatchVariables (bật sẵn trong strict) khiến biến catch có type unknown thay vì any. Nếu bạn từng viết error.message rồi crash vì ai đó throw "oops", đây là lý do.

Phản xạ đầu tiên của nhiều người là chú thích thẳng type Error cho gọn. TypeScript không cho:

1
2
3
4
5
try {
  await saveUser(user);
} catch (error: Error) {
  // ❌ TS1196: Catch clause variable type annotation must be 'any' or 'unknown' if specified.
}

Đây không phải TypeScript làm khó. catch thật sự có thể nhận bất cứ thứ gì, vì throw trong JavaScript không giới hạn type:

1
2
3
4
throw new Error("hỏng rồi"); // Error
throw "hỏng rồi"; // string - hợp lệ!
throw { code: 500 }; // object - cũng hợp lệ
throw undefined; // vẫn hợp lệ 🤷

Một thư viện bên thứ ba throw string là chuyện có thật. Nếu TypeScript cho bạn viết catch (error: Error), nó đang hứa một điều mà runtime không đảm bảo được - nên nó chỉ chấp nhận đúng hai annotation: any (tắt kiểm tra) hoặc unknown (bắt bạn kiểm tra).

Vì vậy trong strict, ba cách viết sau là:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
try {
  await saveUser(user);
} catch (error) {
  // error: unknown (mặc định trong strict)
  toast(error.message); // ❌ 'error' is of type 'unknown'
}

try {
  await saveUser(user);
} catch (error: any) {
  // Compile được, nhưng bạn vừa tự tắt lưới an toàn
  toast(error.message); // 💥 undefined nếu ai đó throw "oops"
}

try {
  await saveUser(user);
} catch (error: unknown) {
  // ✅ Giống mặc định, nhưng ghi rõ ý định
  toast(error instanceof Error ? error.message : "Đã có lỗi xảy ra");
}

Trong thực tế, viết narrow một lần rồi tái sử dụng khắp app:

1
2
3
4
5
6
7
8
9
10
11
function getErrorMessage(error: unknown): string {
  if (error instanceof Error) return error.message;
  if (typeof error === "string") return error; // thư viện throw string
  return "Đã có lỗi không xác định";
}

try {
  await saveUser(user);
} catch (error) {
  toast(getErrorMessage(error)); // ✅ an toàn với mọi kiểu throw
}

getErrorMessage nhận unknown nên gọi được ở mọi catch, và luôn trả về string - component không bao giờ phải nhìn thấy undefined nữa.

3.3. never - trường hợp không thể xảy ra

never là type không có giá trị nào thuộc về nó. Dùng cho hàm không bao giờ return bình thường:

1
2
3
function fail(message: string): never {
  throw new Error(message);
}

Nhưng công dụng đáng giá nhất của neverexhaustive check - bắt TypeScript nhắc bạn khi union có thêm member mới:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; size: number };

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.size ** 2;
    default: {
      // Ở đây shape đã bị loại hết → kiểu của nó là never
      const exhaustive: never = shape;
      throw new Error(`Shape chưa xử lý: ${JSON.stringify(exhaustive)}`);
    }
  }
}

Hôm nay code này compile sạch. Ngày mai bạn thêm một shape:

1
2
3
4
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; size: number }
  | { kind: "triangle"; base: number; height: number }; // ← mới thêm

TypeScript lập tức báo lỗi ngay tại dòng const exhaustive, chứ không đợi tới lúc user gặp NaN:

1
2
Type '{ kind: "triangle"; base: number; height: number; }'
  is not assignable to type 'never'.

shape ở nhánh default không còn rỗng nữa - còn sót triangle chưa xử lý, nên nó không gán được vào never. Compiler vừa trở thành checklist của bạn: thêm member vào union, mọi chỗ xử lý thiếu sẽ tự động lộ ra.

Lưu ý

Cặp ngoặc { } quanh nhánh default không phải cho đẹp. Không có nó, const nằm trực tiếp trong case sẽ bị ESLint chặn bởi rule no-case-declarations, vì biến đó rò rỉ ra các nhánh case khác.

Khi nào dùng gì:

TypeDùng khi
unknownData từ ngoài vào: API response, localStorage, catch, user input
neverExhaustive check trong switch, hàm luôn throw
anyGần như không bao giờ - chỉ khi migrate code cũ, và nên kèm // TODO

Quy tắc thực dụng: chỗ nào bạn định gõ any, thử unknown trước. Nếu compiler bắt đầu than phiền, đó không phải phiền phức - đó chính là những chỗ code bạn đang giả định mà chưa kiểm tra.

4. as const - Immutable Tuple và Literal Types

Ở phần 2 bạn đã gặp widening: { type: "INCREMENT" } bị suy ra thành { type: string } và hỏng luôn discriminated union. as const chính là thuốc giải cho chuyện đó - nó bảo TypeScript: “giá trị này không đổi, hãy suy ra type hẹp nhất có thể”.

1
2
3
4
5
const colors = ["red", "green", "blue"];
// string[] ← mất sạch giá trị cụ thể, và có thể push thêm bất cứ gì

const config = { theme: "dark", size: "lg" };
// { theme: string; size: string } ← "dark" biến thành string

Thêm as const, TypeScript giữ nguyên từng literal và khóa cấu trúc lại:

1
2
3
4
5
const colors = ["red", "green", "blue"] as const;
// readonly ["red", "green", "blue"] ← tuple, đúng thứ tự, đúng độ dài

const config = { theme: "dark", size: "lg" } as const;
// { readonly theme: "dark"; readonly size: "lg" }

Mặc định TypeScript widen literal vì nó cho rằng biến sẽ bị gán lại. as const phủ nhận giả định đó - và đổi lại, bạn được type chính xác tới từng giá trị.

4.1. Sinh union type từ một mảng - một source of truth duy nhất

Đây là ứng dụng đáng giá nhất của as const. Bình thường bạn phải khai báo hai lần - một cho type, một cho giá trị runtime - và chúng sẽ lệch nhau sớm thôi:

1
2
3
// ❌ Hai source of truth, sửa một chỗ quên chỗ kia
type Role = "admin" | "editor" | "viewer";
const ROLES = ["admin", "editor", "viewer"]; // dùng để render <select>

Với as const, mảng runtime trở thành nguồn duy nhất, còn type được suy ra từ nó:

1
2
3
4
5
6
const ROLES = ["admin", "editor", "viewer"] as const;
type Role = (typeof ROLES)[number];
// → "admin" | "editor" | "viewer"

const role: Role = "admin"; // ✅
const bad: Role = "owner"; // ❌ Type '"owner"' is not assignable to type '"admin" | "editor" | "viewer"'

Cú pháp (typeof ROLES)[number] nghĩa là “lấy type của mọi phần tử trong ROLES“. Thêm "moderator" vào mảng, Role tự có thêm giá trị đó - và mọi switch trên Role mà thiếu case sẽ báo lỗi ngay (nhờ never ở phần 3). Bạn vừa nối runtime và compile-time vào cùng một dòng code.

4.2. Custom hook trả về tuple

React không dùng as const cho useState - @types/react khai báo sẵn return type là tuple [S, Dispatch<SetStateAction<S>>]. Nhưng khi bạn viết custom hook trả về cặp [value, setter], bạn phải tự lo:

1
2
3
4
5
6
7
8
9
function useToggle() {
  const [on, setOn] = useState(false);
  return [on, () => setOn((v) => !v)]; // ❌ thiếu as const
}

const [isOpen, toggle] = useToggle();
toggle();
// ❌ This expression is not callable.
//    Not all constituents of type 'boolean | (() => void)' are callable.

Không có as const, TypeScript suy ra (boolean | (() => void))[] - một mảng các phần tử “hoặc boolean hoặc hàm”. Thứ tự biến mất, nên toggle có thể là boolean, và gọi nó là lỗi. Thêm as const là xong:

1
2
3
4
5
6
7
function useToggle() {
  const [on, setOn] = useState(false);
  return [on, () => setOn((v) => !v)] as const;
  // → readonly [boolean, () => void]
}

const [isOpen, toggle] = useToggle(); // isOpen: boolean ✅, toggle: () => void ✅

Lưu ý: readonly có thể “lây” sang chỗ khác

as const làm mọi thứ thành readonly, và readonly array không gán được cho tham số mảng thường:

1
2
3
4
5
6
7
8
9
const ROLES = ["admin", "editor", "viewer"] as const;

function shuffle<T>(items: T[]): T[] {
  return [...items].sort();
}

shuffle(ROLES);
// ❌ The type 'readonly ["admin", "editor", "viewer"]' is 'readonly'
//    and cannot be assigned to the mutable type 'unknown[]'

Đây là tính năng, không phải bug - TypeScript đang ngăn bạn mutate một hằng số. Cách xử lý: khai báo tham số là readonly T[] (nên làm, vì hàm của bạn vốn không mutate), hoặc copy mảng bằng [...ROLES] khi thật sự cần bản mutable.

1
2
3
4
5
function shuffle<T>(items: readonly T[]): T[] {
  return [...items].sort();
}

shuffle(ROLES); // ✅

5. Generic trong TSX - Tránh Lỗi Parse

Trong file .ts, generic arrow function viết bình thường không sao. Nhưng chuyển đúng dòng đó sang file .tsx, nó vỡ:

1
2
const identity = <T>(value: T): T => value;
// ❌ TS17008: JSX element 'T' has no corresponding closing tag.

Lỗi nghe rất khó hiểu vì chẳng có JSX nào ở đây cả. Nguyên nhân: trong .tsx, parser gặp <T> và hiểu đó là thẻ JSX mở, rồi đi tìm </T> cho tới hết file. Cùng một dòng code, đổi đuôi file là đổi ý nghĩa.

Có hai cách sửa:

1
2
3
4
5
6
7
// ✅ 1. Function declaration - rõ ràng nhất, không dính parser
function identity<T>(value: T): T {
  return value;
}

// ✅ 2. Thêm dấu phẩy sau T - báo cho parser biết đây là type param, không phải JSX
const identity2 = <T,>(value: T): T => value;

5.1. Generic component thực tế

Generic phát huy tác dụng khi bạn viết component dùng lại được với mọi loại data mà vẫn giữ type an toàn:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import type { ReactNode } from "react";

type ListProps<T> = {
  items: readonly T[];
  renderItem: (item: T) => 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>
  );
}

Chỗ hay nằm ở nơi gọi: bạn không cần viết <List<User> ... />. TypeScript nhìn vào items rồi tự suy ra T, và từ đó biết luôn type của tham số trong hai callback:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
interface User {
  id: string;
  name: string;
}

const users: User[] = [{ id: "1", name: "An" }];

<List
  items={users}
  renderItem={(user) => <span>{user.name}</span>} // user: User - tự infer
  keyExtractor={(user) => user.id} // user: User - tự infer
/>;

// Gõ sai tên field → báo lỗi ngay
<List
  items={users}
  renderItem={(user) => <span>{user.fullName}</span>} // ❌ Property 'fullName' does not exist on type 'User'
  keyExtractor={(user) => user.id}
/>;

Đổi users thành Product[], cả hai callback tự đổi theo - không sửa gì thêm. Đó là khác biệt giữa generic và items: any[]: any[] cũng “hoạt động với mọi type”, nhưng bên trong callback bạn mất sạch autocomplete và không ai chặn user.fullName cả.

Lưu ý

items: readonly T[] chứ không phải T[] - để component nhận được cả mảng thường lẫn mảng as const từ phần 4. Component chỉ đọc items, không mutate, nên readonly là chữ ký đúng.

6. Parameters và ReturnType - Derive Types Từ Functions

Bạn viết một hàm, rồi viết thêm một interface mô tả kết quả của nó. Hai tháng sau, hàm thêm một field - và interface thì không. Type nói một đằng, runtime làm một nẻo, TypeScript vẫn compile sạch vì bạn đã tự tay nói dối nó.

Cách thoát: đừng khai báo song song, hãy derive thẳng từ function.

1
2
3
4
5
6
7
8
9
10
11
12
13
function createUser(name: string, age: number, role: "admin" | "user") {
  const id: string = crypto.randomUUID();
  return { id, name, age, role, createdAt: new Date() };
}

type CreateUserParams = Parameters<typeof createUser>;
// → [name: string, age: number, role: "admin" | "user"]

type User = ReturnType<typeof createUser>;
// → { id: string; name: string; age: number; role: "admin" | "user"; createdAt: Date }

// Lấy riêng type của một tham số
type UserRole = Parameters<typeof createUser>[2]; // "admin" | "user"

Thêm field avatar vào createUser, User tự có avatar. Không có bước thứ hai để quên.

Chú ý typeof createUser: Parameters nhận vào một type, còn createUser là một giá trị. typeof là cầu nối giữa hai thế giới đó - thiếu nó là lỗi ngay.

6.1. Hàm async: nhớ Awaited

Đây là chỗ hầu hết mọi người vấp. ReturnType của một hàm async không phải là data - nó là Promise bọc quanh data:

1
2
3
4
5
6
7
8
9
10
async function fetchUser(userId: string): Promise<{ id: string; name: string }> {
  const res = await fetch(`/api/users/${userId}`);
  return res.json();
}

type Wrong = ReturnType<typeof fetchUser>;
// → Promise<{ id: string; name: string }>  ← vẫn còn Promise!

type Right = Awaited<ReturnType<typeof fetchUser>>;
// → { id: string; name: string }  ← đúng cái bạn cần

Awaited<T> (có từ TypeScript 4.5) bóc lớp Promise ra. Quy tắc dễ nhớ: hễ derive từ hàm async, gần như luôn cần Awaited. Quên nó, bạn sẽ nhận những lỗi kiểu Property 'name' does not exist on type 'Promise<...>' và ngồi gãi đầu.

6.2. Derive type từ custom hook

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { useState, useCallback } from "react";

async function fetchUser(userId: string): Promise<User> {
  const res = await fetch(`/api/users/${userId}`);
  return res.json();
}

function useUserData(userId: string) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(false);

  const refetch = useCallback(async () => {
    setLoading(true);
    setUser(await fetchUser(userId));
    setLoading(false);
  }, [userId]);

  return { user, loading, refetch };
}

type UserData = ReturnType<typeof useUserData>;
// → { user: User | null; loading: boolean; refetch: () => Promise<void> }

Giờ truyền nguyên cụm state xuống component con mà không phải khai báo lại type:

1
2
3
4
5
function UserSection({ data }: { data: UserData }) {
  if (data.loading) return <Spinner />;
  if (!data.user) return <EmptyState onRetry={data.refetch} />; // bắt buộc: user có thể null
  return <UserCard user={data.user} />; // ✅ tới đây TypeScript biết user: User
}

Để ý dòng if (!data.user). Vì UserData derive trung thực từ hook, nó giữ nguyên user: User | null - và TypeScript ép bạn xử lý trường hợp null trước khi chạm vào data.user. Một interface viết tay rất dễ ghi đại thành user: User cho tiện, và thế là bạn vừa tự tay tạo ra một Cannot read properties of null chờ sẵn ở production.

Lưu ý: derive không phải lúc nào cũng đúng

ReturnType<typeof useUserData> biến implementation thành contract. Đổi tên refetch thành reload, mọi component dùng UserData vỡ theo - đôi khi đó là điều bạn muốn, đôi khi không.

Với code nội bộ, derive là lựa chọn tốt: ít lặp, không bao giờ lệch. Nhưng với API công khai (thư viện, package dùng chung), hãy viết type tường minh - nó là lời hứa bạn đưa ra cho người dùng, không nên tự đổi mỗi khi bạn refactor bên trong.

Sáu pattern trên đều xoay quanh cùng một ý tưởng: mô tả cho TypeScript đúng những gì hợp lệ, không hơn. string thay vì union, data?: T thay vì discriminated union, any thay vì unknown, interface viết tay thay vì ReturnType - lần nào cũng là bạn đang nói với compiler “cứ cho qua đi”, rồi tự đi tìm bug đó bằng tay lúc 11 giờ đêm.

Không cần áp dụng cả sáu ngay hôm nay. Lần tới khi bạn định gõ status: string hay catch (e: any), dừng một giây và hỏi: giá trị nào mới thực sự hợp lệ ở đây? Trả lời được câu đó, TypeScript sẽ lo phần còn lại.

← Previous
Typing Props và Components Trong React TypeScript: Từ Events Đến Polymorphic
Table of Contents
  1. 1. Union Types và Template Literal Types
  2. 2. Discriminated Union
    1. 2.1. Discriminated union cho reducer
  3. 3. unknown vs any vs never
    1. 3.1. any - cánh cửa thoát hiểm bạn không nên mở
    2. 3.2. unknown - “chưa biết” chứ không phải “khỏi cần biết”
    3. 3.3. never - trường hợp không thể xảy ra
  4. 4. as const - Immutable Tuple và Literal Types
    1. 4.1. Sinh union type từ một mảng - một source of truth duy nhất
    2. 4.2. Custom hook trả về tuple
  5. 5. Generic trong TSX - Tránh Lỗi Parse
    1. 5.1. Generic component thực tế
  6. 6. Parameters và ReturnType - Derive Types Từ Functions
    1. 6.1. Hàm async: nhớ Awaited
    2. 6.2. Derive type từ custom hook