TypeScript Production · Phần 4 — Generics, Inference & API Design
Thiết kế generic API để compiler suy luận từ input, dùng constraints, const type parameters, overload đúng chỗ và tránh conditional type làm public API khó hiểu.
Generic tốt làm người gọi viết ít type hơn nhưng nhận kết quả chính xác hơn. Generic tệ bắt người gọi truyền type argument, assertion và hiểu implementation của bạn.
Type parameter phải nối ít nhất hai vị trí
function first<T>(items: readonly T[]): T | undefined {
return items[0];
}
T nối input với output. Nếu type parameter chỉ xuất hiện một lần, thường bạn không cần generic.
// thừa generic
function length<T extends { length: number }>(value: T): number {
return value.length;
}
// rõ hơn
function length(value: { length: number }): number {
return value.length;
}
Constraint mô tả năng lực tối thiểu
function pluck<T, K extends keyof T>(rows: readonly T[], key: K): Array<T[K]> {
return rows.map((row) => row[key]);
}
Đừng constraint bằng một domain type lớn nếu algorithm chỉ cần { id: string }. Constraint hẹp tăng khả năng tái dùng và giảm coupling.
Literal inference là một phần UX
function defineRoutes<const T extends Record<string, `/${string}`>>(
routes: T
): T {
return routes;
}
const routes = defineRoutes({ home: '/', user: '/users/:id' });
// giữ literal thay vì widen thành string
const type parameter yêu cầu compiler ưu tiên inference literal/readonly. Dùng khi literal là dữ liệu cho API tiếp theo; đừng dùng chỉ để hover trông “xịn”.
satisfies kiểm shape mà không thay type suy luận của expression:
type RouteMap = Record<string, `/${string}`>;
const routes = {
home: '/',
users: '/users',
} satisfies RouteMap;
Infer từ dữ liệu, không bắt khai báo lặp
type EventMap = {
saved: { id: string };
failed: { error: Error };
};
class Emitter<Events extends Record<string, unknown>> {
emit<K extends keyof Events>(name: K, payload: Events[K]) {}
}
const bus = new Emitter<EventMap>();
bus.emit('saved', { id: '1' });
Ở đây caller khai báo event map một lần; key và payload luôn correlated.
Overload hay union?
Dùng union khi output không đổi theo input:
function normalize(value: string | URL): string {
return value instanceof URL ? value.href : value;
}
Dùng overload khi các call shape có output khác nhau và chỉ vài trường hợp:
function get(id: string): Promise<User>;
function get(ids: readonly string[]): Promise<User[]>;
function get(input: string | readonly string[]): Promise<User | User[]> {
// implementation
throw new Error('demo');
}
Nếu overload tăng nhanh theo tích Descartes của options, API đang có quá nhiều mode. Tách function hoặc dùng discriminated options.
Conditional return type: mạnh nhưng đắt
type Output<T> = T extends 'json' ? object : string;
declare function parse<T extends 'json' | 'text'>(mode: T): Output<T>;
Public conditional type hợp lý khi correlation thật sự mang giá trị. Nhưng implementation thường cần assertion vì compiler không thể narrow generic T theo runtime branch như union cụ thể. Đừng đẩy complexity vào library chỉ để tiết kiệm một function name.
NoInfer khi một input không được “bỏ phiếu”
function choose<C extends string>(
choices: readonly C[],
fallback: NoInfer<C>
): C {
return choices.includes(fallback) ? fallback : choices[0]!;
}
choose(['red', 'green'] as const, 'blue'); // lỗi
Không có NoInfer, fallback có thể làm C widen để tự hợp thức hóa chính nó.
API review rubric
- Caller có phải truyền type mà input đã chứa không?
- Invalid call fail tại argument gần nguyên nhân hay tạo error dài trong generic internals?
- Hover type có đọc được không?
- Thêm option có phá inference hiện tại không?
- API có inference test và negative test không?
Lab
- Refactor một helper có explicit type argument thành inference từ parameter.
- Dùng
satisfiescho config và chứng minh literal vẫn được giữ. - Viết
Emitter<EventMap>vớion,off,emitkhông dùngany. - So sánh diagnostics của overload, union và discriminated options trên một API thật.
Done khi: happy path không cần generic syntax ở call site; invalid path báo lỗi ngắn, đúng vị trí.