Generic thường được giới thiệu như cú pháp thay thế any: đặt một type parameter giữa cặp dấu ngoặc nhọn, dùng lại nó ở vài vị trí, và hàm lập tức trở nên “type-safe”. Cách mô tả đó bỏ qua phần khó nhất. Một generic API hữu ích phải giữ được quan hệ giữa các giá trị, chỉ chấp nhận những phép thay thế mà implementation thực sự xử lý được, và suy luận ra kiểu có ích mà không bắt caller chú thích mọi biểu thức.
Phương sai là ngôn ngữ dùng để nói về những phép thay thế đó. Ràng buộc mô tả năng lực tối thiểu mà type parameter phải có. Suy luận kiểu quyết định lượng thông tin còn được giữ lại sau một lời gọi. Ba vấn đề gặp nhau tại ranh giới API: collection, callback, event handler, serializer, repository và state container. Làm sai một phần có thể khiến signature cứng nhắc không cần thiết, hoặc tệ hơn, hứa một bảo đảm an toàn mà runtime không thể giữ.
Các ví dụ dưới đây giả định strict: true, đặc biệt là strictFunctionTypes. Khi tắt strict checking, quy tắc tương thích tham số dễ dãi hơn và nhiều khác biệt quan trọng sẽ biến mất.
Ràng buộc phải giữ lại thông tin
Constraint là cận trên, không phải kiểu dùng để thay thế kết quả. Hãy so sánh một hàm nhận interface rộng với một hàm ghi lại chính xác kiểu của caller:
type Identified = { id: string };
function indexByIdLoose(values: Identified[]): Map<string, Identified> {
return new Map(values.map((value) => [value.id, value]));
}
function indexById<T extends Identified>(values: readonly T[]): Map<string, T> {
return new Map(values.map((value) => [value.id, value]));
}
const users = [
{ id: 'u1', name: 'Ada', role: 'admin' as const },
{ id: 'u2', name: 'Lin', role: 'reader' as const },
];
const loose = indexByIdLoose(users).get('u1');
// Identified | undefined: name và role đã bị xóa khỏi kiểu.
const precise = indexById(users).get('u1');
// { id: string; name: string; role: 'admin' | 'reader' } | undefined
Implementation chỉ cần id, nên T extends Identified cấp đúng năng lực đó đồng thời giữ mọi field bổ sung. Tham số readonly T[] còn nói rõ hàm chỉ đọc chứ không sửa input, vì vậy caller có thể truyền cả array mutable lẫn readonly.
Constraint đặc biệt hiệu quả khi biểu diễn quan hệ giữa các tham số:
function pluck<T, K extends keyof T>(
values: readonly T[],
key: K,
): Array<T[K]> {
return values.map((value) => value[key]);
}
const names = pluck(users, 'name'); // string[]
const roles = pluck(users, 'role'); // Array<'admin' | 'reader'>
// pluck(users, 'missing'); // Lỗi: không phải key của element type.
K extends keyof T không chỉ từ chối một string sai. Nó nối key đã chọn với return type qua T[K]. Signature như (values: object[], key: string) => unknown[] có thể chạy cùng một vòng lặp ở runtime, nhưng làm mất quan hệ mà caller cần.
Inference vẫn có thể widen literal khi giá trị phải phù hợp với một vị trí mutable. Dùng as const hoặc const type parameter khi việc giữ nguyên cấu trúc literal là một phần của contract:
function defineRoutes<const T extends readonly string[]>(routes: T): T {
return routes;
}
const routes = defineRoutes(['/users', '/settings']);
// readonly ['/users', '/settings']
Đừng thêm type parameter không biểu diễn quan hệ nào. Hàm parse<T>(text: string): T không suy luận hay kiểm chứng được T; nó chỉ cho caller tự khẳng định kết quả mong muốn. Hãy trả về unknown, validate rồi narrow giá trị.
Note
Dùng unknown khi giá trị tồn tại nhưng kiểu chưa được xác lập. Mọi thao tác trên nó đều cần narrowing. any tắt kiểm tra và lan qua biểu thức, nên chỉ nên nằm trong ranh giới interop rất hẹp, sau đó phải validate. Một generic type parameter không phải cách viết an toàn hơn của any nếu implementation không thể thực sự bảo đảm kiểu do caller chọn.
Phương sai mô tả phép thay thế an toàn
Giả sử Admin là subtype của User. Generic constructor Box<T> là covariant nếu có thể dùng Box<Admin> ở nơi cần Box<User>. Nó là contravariant nếu chiều bị đảo: có thể dùng Handler<User> ở nơi cần Handler<Admin>. Nó là invariant nếu nhìn chung cả hai phép thay thế đều không an toàn.
Câu hỏi quyết định là T xuất hiện ở đâu. Producer trả về T; consumer nhận T; container mutable thường thực hiện cả hai.
| Cấu trúc | Vị trí của T |
Chiều an toàn | Ví dụ điển hình |
|---|---|---|---|
| Producer | Output | Covariant | () => T, ReadonlyArray<T> |
| Consumer | Input | Contravariant | (value: T) => void |
| Producer và consumer | Input và output | Invariant | Cell hoặc channel mutable |
| Phantom | Không dùng trong cấu trúc | Độc lập/bivariant | Marker metadata, thường là dấu hiệu cần xem lại thiết kế |
TypeScript suy ra variance từ cách type parameter được dùng trong cấu trúc. Ngôn ngữ cũng hỗ trợ annotation out T, in T và in out T trên type alias và interface, nhưng đây là công cụ kiểm tra và tối ưu nâng cao, không thể thay cho một cấu trúc trung thực. Với phần lớn application code, để chính các member thể hiện type đang produce hay consume T sẽ dễ hiểu hơn.
interface Producer<out T> {
get: () => T;
}
interface Consumer<in T> {
accept: (value: T) => void;
}
interface Cell<in out T> {
get: () => T;
set: (value: T) => void;
}
Có thể dùng Producer<Admin> ở nơi cần Producer<User> vì mọi admin được tạo ra đều là user. Có thể dùng Consumer<User> ở nơi cần Consumer<Admin> vì nó đã biết cách nhận mọi user, bao gồm admin. Không thể biến Cell<Admin> thành Cell<User> một cách an toàn: ai đó có thể ghi một user thông thường vào cell. Chiều ngược lại cũng không thể hứa rằng giá trị đọc ra luôn là admin.
Callback đảo chiều tương thích
Callback API là nơi contravariance trở thành vấn đề thực tế thay vì lý thuyết. Một callback xử lý được mọi User chắc chắn xử lý được stream chỉ chứa Admin. Callback chỉ hiểu Admin thì không thể xử lý một User bất kỳ.
type User = { id: string };
type Admin = User & { permissions: readonly string[] };
type Handler<T> = (value: T) => void;
const handleUser: Handler<User> = (user) => {
console.log(user.id);
};
const handleAdmin: Handler<Admin> = (admin) => {
console.log(admin.permissions.join(', '));
};
let adminHandler: Handler<Admin> = handleUser; // An toàn.
// let userHandler: Handler<User> = handleAdmin; // Lỗi với strictFunctionTypes.
Chiều này thường có vẻ ngược vì phép gán nói về năng lực của function, không đi theo mũi tên kế thừa của parameter. Hãy hỏi eventual caller có thể truyền những giá trị nào. Một slot Handler<User> có thể nhận user thông thường, nên function chỉ xử lý admin là quá hẹp.
Có một ngoại lệ quan trọng. Để tương thích lịch sử với các pattern class và DOM phổ biến trong JavaScript, TypeScript kiểm tra method parameter theo kiểu bivariant. Vì vậy method syntax có thể chấp nhận cả hai chiều trong trường hợp function property sẽ từ chối chiều không an toàn:
interface UnsafeListener<T> {
onValue(value: T): void; // Method parameter: bivariant compatibility.
}
interface StrictListener<T> {
onValue: (value: T) => void; // Function property: contravariant.
}
declare let adminOnlyMethod: UnsafeListener<Admin>;
const acceptsUsers: UnsafeListener<User> = adminOnlyMethod; // Được chấp nhận.
// Nguy hiểm ở runtime: acceptsUsers.onValue({ id: 'u1' }) có thể đi vào
// code giả định permissions luôn tồn tại.
Warning
Method bivariance là một soundness compromise có chủ đích, không phải bằng chứng phép gán an toàn. Với public callback contract nơi chiều parameter có ý nghĩa, hãy ưu tiên function-valued property như onValue: (value: T) => void. Luôn bật strictFunctionTypes.
Callback lồng bên trong một function khác cũng cần cùng cách phân tích. Trong API như subscribe<T>(source: Source<T>, observer: (value: T) => void), observer consume giá trị. Đừng buộc nó phải dùng đúng subtype hẹp đã infer từ source nếu một callback rộng hơn vẫn hợp lệ. Ngược lại, không chấp nhận callback hẹp hơn chỉ vì implementation hôm nay tình cờ chỉ emit một subtype.
Readonly tăng cả an toàn lẫn khả năng tái sử dụng
Mutable array bộc lộ một trong những góc unsound nổi tiếng nhất của TypeScript. Compiler cho phép gán Admin[] vào User[], chủ yếu vì lý do tiện dụng và tương thích lịch sử. Mutation sau đó có thể phá vỡ lời hứa về element của array ban đầu:
const admins: Admin[] = [
{ id: 'a1', permissions: ['deploy'] },
];
const usersView: User[] = admins; // TypeScript cho phép.
usersView.push({ id: 'u2' }); // Chèn một non-admin.
admins[1]?.permissions.join(', '); // Runtime failure nếu truy cập thiếu kiểm tra.
Đây không phải covariance sound. Nó là tradeoff có chủ đích của ngôn ngữ. ReadonlyArray<T> loại bỏ thao tác mutation, khiến covariance có cơ sở vì consumer chỉ có thể quan sát element:
function firstId(values: readonly User[]): string | undefined {
return values[0]?.id;
}
const readonlyAdmins: readonly Admin[] = admins;
firstId(readonlyAdmins); // An toàn: function không thể chèn User.
Hãy đánh dấu input collection là readonly bất cứ khi nào implementation chỉ đọc. Điều đó vừa ghi lại intent, vừa mở rộng tập caller hợp lệ. Với abstraction thực sự mutable, đừng giả vờ nó covariant. Thay vào đó, tách các capability:
interface ReadableStore<out T> {
read: () => T;
}
interface WritableStore<in T> {
write: (value: T) => void;
}
interface Store<T> extends ReadableStore<T>, WritableStore<T> {}
Client chỉ đọc nhận ReadableStore<T> và có covariance an toàn. Writer nhận capability contravariant. Chỉ code thật sự cần cả hai mới nhận Store<T> invariant. Interface hướng theo capability dễ thay thế, mock và tiến hóa hơn một interface mutable lớn.
Type assertion có thể vượt qua mọi kiểm tra trên; any, unchecked indexed access và declaration file không chính xác cũng vậy. TypeScript nhắm tới static guarantee hữu ích trên JavaScript chứ không bảo đảm soundness hoàn toàn. API tốt giảm số escape hatch và validate dữ liệu tại nơi tri thức tĩnh kết thúc, đặc biệt ở ranh giới network, storage và JSON.parse.
Thiết kế inference trước khi thêm overload
Overload hữu ích khi return type thay đổi theo những nhánh thật sự rời rạc, nhưng nó có thể che mất quan hệ và tạo ra inference bất ngờ. Caller chỉ nhìn thấy overload signature; implementation signature không thể được gọi từ bên ngoài.
function format(value: string): string;
function format(value: number): string;
function format(value: string | number): string {
return typeof value === 'number' ? value.toFixed(2) : value.trim();
}
const input: string | number = Math.random() > 0.5 ? 'ok' : 42;
// format(input); // Lỗi: không overload riêng lẻ nào nhận union.
Nếu cả hai nhánh có cùng output, một union signature dễ compose hơn: format(value: string | number): string. Nếu output liên hệ với input, generic hoặc conditional type có thể mô tả quan hệ đó. Cũng cần nhớ các type utility kiểm tra overloaded function thường infer từ signature cuối cùng, thay vì chạy lại overload resolution cho mọi lời gọi.
Đừng bắt inference giải quyết hai nhiệm vụ mâu thuẫn. Nếu một argument phải quyết định T, còn argument khác chỉ nên được kiểm tra theo T đó, NoInfer<T> có thể chặn nguồn inference thứ hai:
function choose<T>(options: readonly T[], fallback: NoInfer<T>): T {
return options[0] ?? fallback;
}
choose(['red', 'green'] as const, 'red');
// choose(['red', 'green'] as const, 'blue'); // Lỗi.
Checklist thực tế để review một generic API khá ngắn:
- Gọi tên quan hệ mà mỗi type parameter biểu diễn; bỏ parameter chỉ được dùng một lần trừ khi nó constrain type khác.
- Chỉ cấp cho constraint năng lực tối thiểu implementation cần.
- Giữ thông tin của caller trong return type thay vì widen thành constraint.
- Đánh dấu input chỉ đọc bằng
readonly, đồng thời tách read khỏi write khi có thể. - Kiểm tra mọi callback parameter với
strictFunctionTypes; dùng function property khi method bivariance làm yếu contract. - Ưu tiên union hoặc một relational generic thay cho danh sách overload dài.
- Giữ
unknownở ranh giới không đáng tin cậy, validate nó, và cô lậpanyhoặc assertion không thể tránh.
Điều cần ghi nhớ
Generic có giá trị vì giữ lại quan hệ, không phải vì dấu ngoặc nhọn trông chính xác hơn any. Constraint chỉ nên mở những operation implementation cần trong khi vẫn giữ concrete type của caller. Inference nên chảy từ input có thẩm quyền, và overload không nên thay thế một quan hệ có thể diễn đạt bằng một signature.
Variance trả lời liệu một generic instantiation có thể đứng thay cho instantiation khác một cách an toàn hay không. Producer tự nhiên là covariant, consumer là contravariant, còn đối tượng mutable vừa produce vừa consume là invariant. TypeScript cố ý nới mô hình này cho mutable array và method parameter, vì vậy hiểu mô hình sound vẫn quan trọng ngay cả khi compiler chấp nhận nhiều hơn.
API vững chắc nhất làm capability trở nên rõ ràng: readonly input, interface read/write tách biệt, function-valued callback, constraint hẹp, và unknown được validate tại runtime boundary. Những lựa chọn này không chỉ làm type checker hài lòng. Chúng cho maintainer biết chính xác implementation có thể tôn trọng những phép thay thế nào.