TypeScript generics thực chiến: từ constraint, inference đến typed API client
Khi nào thật sự cần generic, cách dùng extends/keyof, const type parameter, NoInfer, satisfies, conditional và mapped type, rồi ghép lại thành API client lấy type từ zod schema và component List<T> cho React.
Mình từng review một PR có hàm fetchJson<T>(url: string): Promise<T> được gọi ở 40 chỗ. Nhìn thì rất "type-safe": chỗ nào cũng có fetchJson<Order>(...), fetchJson<User[]>(...). Thực tế thì T chẳng được kiểm tra gì cả. Server đổi total từ number sang string, TypeScript vẫn xanh, còn production in ra NaN đ trên trang thanh toán.
Generic không làm code an toàn hơn chỉ vì có dấu <T>. Nó an toàn khi type parameter nối thông tin giữa input và output, và khi chỗ dữ liệu đi vào hệ thống (network, JSON.parse, localStorage) được kiểm tra ở runtime. Bài này đi qua từng công cụ generic mình dùng hằng ngày, kèm code chạy được và cái bẫy đi kèm từng công cụ.
Đọc xong bạn sẽ viết được helper pick/groupBy có type đúng, biết khi nào dùng const type parameter, NoInfer, satisfies, tránh được bẫy distributive conditional type, và có một API client mà type response lấy thẳng từ zod schema thay vì từ niềm tin.
TL;DR:
- Type parameter nên xuất hiện ít nhất hai lần trong signature. Nếu chỉ xuất hiện một lần, bạn cần
unknownhoặc đang cast trá hình. K extends keyof Tcộng vớiT[K]giải quyết phần lớn helper làm việc với object.const T(TS 5.0),NoInfer<T>(TS 5.4) vàsatisfies(TS 4.9) dùng để điều khiển inference, không phải để trang trí.- Conditional type mặc định phân phối qua union. Bọc
[T]khi bạn không muốn điều đó. - Type của dữ liệu từ network phải đến từ schema được validate (
z.infer), không phải từfetch<T>.
Khi nào thật sự cần generic: quy tắc "xuất hiện ít nhất hai lần"
Quy tắc này có trong mục "Guidelines for Writing Good Generic Functions" của TypeScript Handbook: type parameter phải liên hệ ít nhất hai giá trị với nhau. Nếu T chỉ xuất hiện một lần, nó không liên hệ gì cả.
// Sai: T chỉ xuất hiện một lần, generic không mang thêm thông tin nào
function logValue<T>(value: T): void {
console.log(value)
}
// Sai và nguy hiểm hơn: T chỉ nằm ở return type = một cú cast trá hình
function parseJson<T>(raw: string): T {
return JSON.parse(raw)
}
const order = parseJson<{ id: string; total: number }>('{"id": 1}')
// compile OK, nhưng order.total là undefined lúc runtime
// Đúng: T nối input với output
function firstOrThrow<T>(items: readonly T[], message = 'Danh sách rỗng'): T {
const item = items[0]
if (item === undefined) throw new Error(message)
return item
}
const invoice = firstOrThrow([{ id: 'inv_1', amountVnd: 120_000 }])
// invoice: { id: string; amountVnd: number }
// Đúng (thay cho logValue): không cần generic
function logValueSafe(value: unknown): void {
console.log(value)
}
parseJson<T> là mẫu mình thấy nhiều nhất. Nó trông giống generic nhưng thực chất là JSON.parse(raw) as T viết dài hơn. Cách viết thật thà là trả về unknown và bắt caller validate, hoặc nhận một schema làm tham số (phần typed API client bên dưới).
Một dấu hiệu khác: nếu bạn luôn phải truyền type argument bằng tay (foo<User>(...)) và TypeScript không bao giờ tự suy ra được, rất có thể type đó không gắn với tham số nào. Generic tốt thường được gọi mà không cần viết <...>.
Constraint với extends, keyof và indexed access
extends trong type parameter là ràng buộc: "T phải ít nhất có hình dạng này". Kết hợp với keyof và indexed access T[K], bạn viết được helper làm việc với object mà vẫn giữ đúng type của từng field.
function pick<T extends object, K extends keyof T>(obj: T, keys: readonly K[]): Pick<T, K> {
const result = {} as Pick<T, K>
for (const key of keys) {
result[key] = obj[key]
}
return result
}
interface User {
id: string
email: string
passwordHash: string
role: 'admin' | 'member'
}
const user: User = { id: 'u_1', email: 'an@shop.vn', passwordHash: '...', role: 'member' }
const publicUser = pick(user, ['id', 'email', 'role'])
// publicUser: Pick<User, "id" | "email" | "role">, không có passwordHash
// pick(user, ['phone']) -> lỗi: "phone" không thuộc keyof User
// groupBy theo hàm lấy key
function groupBy<T, K extends PropertyKey>(
items: readonly T[],
getKey: (item: T) => K,
): Map<K, T[]> {
const groups = new Map<K, T[]>()
for (const item of items) {
const key = getKey(item)
const bucket = groups.get(key)
if (bucket) bucket.push(item)
else groups.set(key, [item])
}
return groups
}
// groupBy theo tên field: field đó phải là string | number | symbol
function groupByField<K extends PropertyKey, T extends Record<K, PropertyKey>>(
items: readonly T[],
field: K,
): Map<T[K], T[]> {
return groupBy(items, (item) => item[field])
}
interface Order {
id: string
status: 'pending' | 'paid' | 'shipped'
createdAt: Date
}
declare const orders: Order[]
const byStatus = groupByField(orders, 'status')
// Map<"pending" | "paid" | "shipped", Order[]>
// groupByField(orders, 'createdAt') -> lỗi: Date không phải PropertyKey
Vài chi tiết đáng chú ý:
keyof Tcó thể làstring | number | symbol. Nếu bạn cần ghép key vào template string, dùngK extends keyof T & stringđể loại number và symbol.as Pick<T, K>trongpicklà cast có kiểm soát: nó nằm trong một hàm nhỏ, có test, và signature bên ngoài vẫn chính xác. Cast trong implementation chấp nhận được; cast ở call site thì không.- Mình trả về
Mapthay vì object vìMapkhông có prototype key nhưconstructorhay__proto__, và không biến key number thành string. Nếu muốn object,Object.groupBy(ES2024, Node.js 21+) đã có sẵn và lib của TypeScript type nó làPartial<Record<K, T[]>>. ChữPartialở đó là cố ý: không phải status nào cũng có order.
Inference: default type parameter, const, NoInfer và satisfies
TypeScript suy ra type argument từ giá trị bạn truyền vào. Phần lớn thời gian nó suy đúng. Bốn công cụ dưới đây dùng khi nó suy quá rộng, quá hẹp, hoặc suy từ chỗ bạn không muốn.
// 1) Default type parameter: dùng khi caller thường không quan tâm
interface ApiResult<TData = unknown, TError = { code: string; message: string }> {
data?: TData
error?: TError
}
const health: ApiResult = { data: { ok: true } } // TData = unknown
// 2) const type parameter (TS 5.0): giữ literal type mà không bắt caller viết `as const`
function defineStatuses<const T extends readonly string[]>(statuses: T): T {
return statuses
}
const orderStatuses = defineStatuses(['pending', 'paid', 'shipped'])
// readonly ["pending", "paid", "shipped"]
type OrderStatus = (typeof orderStatuses)[number] // "pending" | "paid" | "shipped"
// Bẫy: constraint mutable
function defineStatusesBad<const T extends string[]>(statuses: T): T {
return statuses
}
const mutableTuple = defineStatusesBad(['pending', 'paid']) // ["pending", "paid"], không readonly
const fromConfig = ['pending', 'paid'] as const
// defineStatusesBad(fromConfig) -> lỗi: readonly tuple không gán được cho string[]
// 3) NoInfer (TS 5.4): không cho một tham số "góp phần" suy ra T
function createMachine<S extends string>(config: { states: readonly S[]; initial: NoInfer<S> }) {
return config
}
createMachine({ states: ['draft', 'submitted', 'approved'], initial: 'draft' })
// createMachine({ states: ['draft', 'submitted'], initial: 'aproved' })
// -> lỗi: "aproved" không thuộc "draft" | "submitted"
// 4) satisfies (TS 4.9): kiểm tra shape nhưng giữ type hẹp
interface RouteConfig {
path: string
auth: boolean
}
const routes = {
orders: { path: '/orders', auth: true },
login: { path: '/login', auth: false },
} satisfies Record<string, RouteConfig>
type RouteName = keyof typeof routes // "orders" | "login"
const routesAnnotated: Record<string, RouteConfig> = {
orders: { path: '/orders', auth: true },
}
type LostNames = keyof typeof routesAnnotated // string, mất tên route
Giải thích từng cái:
Default type parameter giống default argument của hàm. Nó chỉ được dùng khi TypeScript không suy ra được từ argument và caller không truyền. Đặt default là unknown thay vì any: caller quên chỉ định type thì vẫn bị bắt narrow trước khi dùng.
const type parameter chỉ ảnh hưởng tới literal viết trực tiếp trong lời gọi. Nếu bạn khai báo const list = ['pending', 'paid'] ở ngoài rồi truyền list vào, list đã là string[] từ trước, const không cứu được. Constraint cũng nên là readonly string[]. Release notes TS 5.0 cảnh báo rằng với constraint mutable, inference lùi về string[]; bản TypeScript mình thử gần đây (5.9) suy ra tuple mutable như comment ở trên, nhưng caller truyền một mảng as const vẫn bị lỗi vì readonly không gán được cho mutable. Viết readonly thì cả hai kiểu mảng đều qua.
NoInfer<S> đánh dấu vị trí "chỉ kiểm tra, không suy ra". Không có nó, TypeScript gom cả states lẫn initial để suy ra S, nên một lỗi chính tả trong initial đơn giản là mở rộng union thêm một thành viên và không ai báo lỗi. Trước TS 5.4 người ta dùng mẹo S & {} để đạt hiệu ứng tương tự. Giờ dùng NoInfer cho dễ đọc.
satisfies khác type annotation ở chỗ annotation thay type của biến bằng type bạn viết, còn satisfies chỉ kiểm tra rồi giữ nguyên type suy ra. Dùng satisfies cho config object, route map, bảng i18n, nơi bạn vừa muốn bắt lỗi shape vừa muốn giữ tên key cụ thể.
Conditional types với infer và bẫy distributive
Conditional type là if ở cấp type: T extends X ? A : B. Từ khóa infer cho phép "bắt" một phần của type khi pattern khớp.
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T
type ElementOf<T> = T extends readonly (infer E)[] ? E : never
type AsyncReturn<F> = F extends (...args: never[]) => Promise<infer R> ? R : never
async function fetchInvoice(id: string) {
return { id, amountVnd: 250_000, currency: 'VND' as const }
}
type Invoice = AsyncReturn<typeof fetchInvoice>
// { id: string; amountVnd: number; currency: "VND" }
// Bẫy 1: conditional type trên type parameter "trần" phân phối qua union
type ToArray<T> = T extends unknown ? T[] : never
type A = ToArray<string | number> // string[] | number[]
// Bọc trong tuple để tắt phân phối
type ToArrayWhole<T> = [T] extends [unknown] ? T[] : never
type B = ToArrayWhole<string | number> // (string | number)[]
// Bẫy 2: never là union rỗng, phân phối qua nó ra never
type IsNever<T> = T extends never ? true : false
type C = IsNever<never> // never, không phải true
type IsNeverFixed<T> = [T] extends [never] ? true : false
type D = IsNeverFixed<never> // true
Distributive conditional type chỉ xảy ra khi phần bên trái extends là type parameter đứng một mình (T, không phải T[] hay [T]). Khi đó ToArray<string | number> được tính như ToArray<string> | ToArray<number>. Hành vi này hữu ích cho các helper như Exclude<T, U> (lọc từng thành viên union), nhưng gây bất ngờ khi bạn muốn coi union là một khối.
Quy tắc mình dùng: viết conditional type xong, test ngay với một union và với never. Hai input đó lộ ra 90% bug ở loại type này. Bạn có thể giữ những dòng type C = IsNever<never> như trên trong một file *.test-d.ts (Vitest có expectTypeOf cho việc này) để không ai sửa helper làm đổi hành vi.
never[] trong AsyncReturn là chủ ý: một hàm nhận (id: string) gán được cho (...args: never[]) => ... vì tham số so sánh theo kiểu contravariant. Viết (...args: unknown[]) thì hàm có tham số cụ thể sẽ không khớp.
Mapped types với key remapping và template literal types
Mapped type duyệt qua các key để tạo type mới. Từ TS 4.1, mệnh đề as cho phép đổi tên key, và trả về never để bỏ key. Ghép với template literal type, bạn sinh được tên event, tên handler, tên action một cách nhất quán.
interface OrderEvents {
created: { orderId: string; totalVnd: number }
paid: { orderId: string; paidAt: Date }
cancelled: { orderId: string; reason: string }
}
// Tên event có namespace
type OrderEventName = `order:${keyof OrderEvents & string}`
// "order:created" | "order:paid" | "order:cancelled"
// Đổi tên key: created -> onCreated
type EventHandlers<E> = {
[K in keyof E & string as `on${Capitalize<K>}`]: (payload: E[K]) => void
}
type OrderHandlers = EventHandlers<OrderEvents>
// { onCreated: (payload: {...}) => void; onPaid: ...; onCancelled: ... }
// Lọc key theo type của value
type PickByValue<T, V> = { [K in keyof T as T[K] extends V ? K : never]: T[K] }
type PaidDateFields = PickByValue<OrderEvents['paid'], Date> // { paidAt: Date }
// Event emitter có type
class TypedEmitter<E extends object> {
private listeners = new Map<keyof E, Set<(payload: never) => void>>()
on<K extends keyof E>(name: K, listener: (payload: E[K]) => void): () => void {
const set = this.listeners.get(name) ?? new Set()
set.add(listener)
this.listeners.set(name, set)
return () => set.delete(listener)
}
emit<K extends keyof E>(name: K, payload: E[K]): void {
const set = this.listeners.get(name)
if (!set) return
for (const listener of set) (listener as (payload: E[K]) => void)(payload)
}
}
const orderBus = new TypedEmitter<OrderEvents>()
const off = orderBus.on('paid', (p) => console.log(p.paidAt.toISOString()))
orderBus.emit('cancelled', { orderId: 'ord_1', reason: 'out_of_stock' })
// orderBus.emit('paid', { orderId: 'ord_1' }) -> lỗi: thiếu paidAt
off()
Chú ý constraint E extends object thay vì E extends Record<string, unknown>. Đây là bẫy kinh điển: interface không có index signature ngầm định, nên OrderEvents khai báo bằng interface không gán được cho Record<string, unknown>, trong khi khai báo bằng type thì được. Người dùng helper của bạn sẽ nhận một lỗi khó hiểu chỉ vì họ chọn interface.
Bên trong TypedEmitter có một cú cast listener as (payload: E[K]) => void. Map không biết mối liên hệ giữa key K và kiểu listener tương ứng, nên cast ở đây là cái giá hợp lý. Public API (on, emit) vẫn chặt. Cũng là nguyên tắc ở phần trước: cast trong implementation nhỏ, không bắt caller cast.
Typed API client: type response lấy từ zod schema
Quay lại fetchJson<T> ở đầu bài. Cách sửa là đảo ngược chiều: thay vì caller "khai" type, caller truyền một schema, client validate response bằng schema đó, và type trả về được suy ra từ schema. Type và runtime check không bao giờ lệch nhau vì chúng là một.
import { z } from 'zod'
const OrderSchema = z.object({
id: z.string(),
status: z.enum(['pending', 'paid', 'shipped']),
totalVnd: z.number().int().nonnegative(),
createdAt: z.string().transform((value) => new Date(value)),
})
export type Order = z.infer<typeof OrderSchema> // createdAt: Date (type output)
const CreateOrderSchema = z.object({
items: z
.array(z.object({ sku: z.string().min(1), quantity: z.number().int().positive() }))
.min(1),
note: z.string().max(500).optional(),
})
export class ApiError extends Error {
readonly status: number
readonly body: unknown
constructor(status: number, body: unknown, message: string) {
super(message)
this.name = 'ApiError'
this.status = status
this.body = body
}
}
export function createApiClient(baseUrl: string) {
async function request<S extends z.ZodType>(
path: string,
schema: S,
init: RequestInit = {},
): Promise<z.output<S>> {
const res = await fetch(new URL(path, baseUrl), {
...init,
headers: { accept: 'application/json', 'content-type': 'application/json', ...init.headers },
})
const body: unknown = res.status === 204 ? null : await res.json().catch(() => null)
if (!res.ok) throw new ApiError(res.status, body, `${init.method ?? 'GET'} ${path} -> ${res.status}`)
const parsed = schema.safeParse(body)
if (!parsed.success) {
// Server trả về sai contract: lỗi của hệ thống, không phải của người dùng
throw new ApiError(res.status, body, `Response sai schema: ${parsed.error.message}`)
}
return parsed.data
}
return {
orders: {
get: (id: string) => request(`/orders/${encodeURIComponent(id)}`, OrderSchema),
list: () => request('/orders', z.array(OrderSchema)),
// Body nhận type input của schema, response nhận type output
create: (input: z.input<typeof CreateOrderSchema>) =>
request('/orders', OrderSchema, {
method: 'POST',
body: JSON.stringify(CreateOrderSchema.parse(input)),
}),
},
}
}
// Dùng
const api = createApiClient('https://api.shop.vn')
const order = await api.orders.get('ord_123')
order.createdAt.getFullYear() // Date thật, không phải string
Vài điểm quan trọng:
z.inputkhácz.output. Khi schema có.transform()hoặc.default(), type đầu vào và đầu ra khác nhau.createdAtvào làstring, ra làDate.z.inferlà alias củaz.output. Dùng nhầmz.infercho body request là cách nhanh nhất để gửi một objectDatelên server.- Signature
request<S extends z.ZodType>đáp ứng quy tắc hai lần:Sxuất hiện ở tham sốschemavà ở return type. TypeScript suy raStừ schema, caller không bao giờ phải viết<...>. - Chi phí. Validate tốn CPU tỷ lệ với kích thước payload. Với response vài KB thì không đáng kể. Với danh sách hàng chục nghìn dòng, đo trước; có thể validate ở chế độ dev/staging và lấy mẫu ở production, nhưng hãy coi đó là quyết định có chủ đích.
- Phân loại lỗi. Response sai schema là bug tích hợp, nên log kèm
pathvàparsed.error.issuesđể team backend sửa. Đừng nuốt nó thành "Có lỗi xảy ra".
Nếu cả client và server cùng là TypeScript, bạn có thể bỏ hẳn lớp này bằng tRPC; mình có viết riêng về nó ở bài tRPC với Next.js App Router.
Generic React component: List<T> và cú pháp <T,> trong .tsx
Component cũng là hàm, nên cũng generic được. Mục tiêu: renderItem nhận đúng type của phần tử, không phải any.
import { memo, type Key, type ReactNode } from 'react'
interface ListProps<T> {
items: readonly T[]
getKey: (item: T) => Key
renderItem: (item: T, index: number) => ReactNode
emptyState?: ReactNode
}
export function List<T>({ items, getKey, renderItem, emptyState = null }: ListProps<T>) {
if (items.length === 0) return <>{emptyState}</>
return (
<ul>
{items.map((item, index) => (
<li key={getKey(item)}>{renderItem(item, index)}</li>
))}
</ul>
)
}
// Arrow function trong .tsx: <T> bị hiểu là thẻ JSX, nên viết <T,>
interface SelectProps<T> {
options: readonly T[]
value: T
getLabel: (option: T) => string
onChange: (option: T) => void
}
export const Select = <T,>({ options, value, getLabel, onChange }: SelectProps<T>) => (
<select
value={options.indexOf(value)}
onChange={(event) => {
const next = options[Number(event.target.value)]
if (next !== undefined) onChange(next)
}}
>
{options.map((option, index) => (
<option key={getLabel(option)} value={index}>
{getLabel(option)}
</option>
))}
</select>
)
// memo() làm mất generic: cast lại về type gốc
export const MemoList = memo(List) as typeof List
// Dùng: T được suy ra từ items
interface Product {
sku: string
name: string
priceVnd: number
}
declare const products: Product[]
export function ProductList() {
return (
<List
items={products}
getKey={(p) => p.sku}
renderItem={(p) => `${p.name}: ${p.priceVnd.toLocaleString('vi-VN')} đ`}
emptyState="Chưa có sản phẩm"
/>
)
}
Hai bẫy hay gặp:
<T>trong file.tsxvới arrow function bị parser hiểu là thẻ JSX mở. Viết<T,>hoặc<T extends unknown>. Function declaration (function List<T>) không gặp vấn đề này, nên mình ưu tiên dạng đó cho component.- HOC như
memovàforwardReftrả về component không generic.memo(List)có type gần vớiList<unknown>, nênrenderItemmất type. Castas typeof Listlà cách phổ biến. Với React 19,reflà prop bình thường nên không còn cầnforwardRef, tránh được một nửa vấn đề.
Những lỗi thường gặp
1. Generic thừa. Nếu hàm chỉ nhận và trả một type cụ thể, đừng thêm type parameter "cho linh hoạt".
interface User {
id: string
email: string
}
declare function loadUserFromDb(id: string): Promise<User>
// Sai: T không liên quan tới input, caller có thể "khai" bất cứ gì
async function getUserBad<T extends User>(id: string): Promise<T> {
return (await loadUserFromDb(id)) as T
}
// Đúng
async function getUser(id: string): Promise<User> {
return loadUserFromDb(id)
}
2. any rò rỉ qua generic. JSON.parse, res.json(), localStorage.getItem đều trả any hoặc string. Một khi any đi vào generic, nó lây sang mọi type suy ra từ đó.
// Sai: T bị suy ra là any, mọi thứ phía sau đều mất type
function cacheGet<T>(key: string, fallback: T): T {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : fallback
}
// Đúng: chặn any ở biên bằng unknown + validate
function cacheGetSafe<T>(key: string, fallback: T, isValid: (value: unknown) => value is T): T {
const raw = localStorage.getItem(key)
if (raw === null) return fallback
try {
const value: unknown = JSON.parse(raw)
return isValid(value) ? value : fallback
} catch {
return fallback
}
}
Bật rule @typescript-eslint/no-unsafe-return và no-unsafe-assignment (trong preset recommendedTypeChecked) sẽ bắt được kiểu rò rỉ này tự động.
3. Type gymnastics không ai đọc được. Recursive conditional type sâu năm tầng để suy ra path của nested object ("user.address.city") trông ấn tượng trong demo, nhưng làm editor chậm, error message dài hàng trăm dòng, và có thể chạm lỗi TS2589 "Type instantiation is excessively deep and possibly infinite". Quy tắc của mình: nếu một type helper cần hơn 10 dòng hoặc đồng nghiệp không giải thích lại được sau 2 phút, viết lại theo hướng đơn giản hơn (liệt kê union bằng tay, hoặc sinh type bằng codegen).
4. Constraint Record<string, unknown> chặn interface. Như phần mapped types: dùng object hoặc { [K in keyof T]: unknown } nếu bạn chỉ cần "là object".
5. Truyền type argument bằng tay ở mọi chỗ gọi. useState<string>('') là thừa, TypeScript suy được. Chỉ viết rõ khi giá trị khởi tạo hẹp hơn type thật: useState<Order | null>(null).
Checklist
- Rà các hàm generic trong codebase: type parameter nào chỉ xuất hiện một lần thì đổi sang
unknownhoặc type cụ thể. - Tìm
fetch<T>,parseJson<T>,as Tở biên hệ thống và thay bằng schema validate (z.infer/z.output). - Dùng
z.inputcho body request vàz.outputcho response khi schema có transform hoặc default. - Đổi config object và route map sang
satisfiesđể giữ tên key. - Thêm
NoInfercho các tham số chỉ để kiểm tra (initial state, default value) trong API generic. - Với mỗi conditional type tự viết, test với một union và với
never; bọc[T]nếu không muốn phân phối. - Dùng constraint
objectthay vìRecord<string, unknown>trong helper public. - Bật
recommendedTypeCheckedcủa typescript-eslint để bắtanyrò rỉ.