Suy luận kiểu đôi khi trông giống như đọc được ý nghĩ. Viết const total = 1 + 2, editor biết total là một số. Truyền callback vào map, tham số của callback có kiểu dù bên cạnh nó không hề có annotation. Kiểm tra typeof value === "string", union trước đó trở thành string bên trong một nhánh.
Không trường hợp nào dựa trên phỏng đoán. Type checker thu thập bằng chứng từ cú pháp và ngữ cảnh, chuyển bằng chứng thành các ràng buộc rồi tính một kiểu thỏa mãn quy tắc của ngôn ngữ. TypeScript thú vị ở chỗ nó kết hợp nhiều chiến lược suy luận thay vì hiện thực một thuật toán hàn lâm thuần nhất. Nó có suy luận cục bộ, contextual typing, suy luận type parameter cho generic, literal widening, structural subtyping và bộ phân tích control flow. Hiểu mỗi cơ chế hoạt động ở đâu giúp thông báo lỗi bớt bí ẩn, đồng thời giúp ta thiết kế API có chủ đích hơn.
Từ cú pháp đến bài toán ràng buộc
Ở mức đơn giản nhất, một expression tổng hợp kiểu từ chính hình dạng của nó. Numeric literal gợi ra numeric literal type, lời gọi hàm tra các signature của callee, còn object literal đóng góp các property có value được suy luận đệ quy. Checker ghi nhận những quan hệ như “kiểu argument phải gán được cho kiểu parameter” thay vì liệt kê mọi kiểu có thể tồn tại trong chương trình.
Hãy xét một ngôn ngữ suy luận tối giản có chủ ý. Gọi alpha là kiểu chưa biết và <: mang nghĩa “gán được cho.” Khi gọi một hàm giống identity bằng string, ta có thể thu được tập ràng buộc:
Solver chọn một phép thế S cho kiểu chưa biết:
Việc giữ literal type "north" hay widening nó thành string còn phụ thuộc vào vị trí xuất hiện của kiểu chưa biết và khả năng value bị thay đổi sau đó. Policy này không phải điều tất yếu của toán học; nó là lựa chọn thiết kế ngôn ngữ nhằm giữ cho code thông thường dễ sử dụng.
Một bộ thu thập và giải ràng buộc giản lược có thể trông như sau:
infer(expression, expectedType?):
if expression is literal:
return freshLiteralType(expression)
if expression is variable:
return environment.lookup(expression.name)
if expression is call(callee, arguments):
signature = chooseCandidate(infer(callee), arguments)
for each (argument, parameter) in pair(arguments, signature.parameters):
argumentType = infer(argument, parameter.type)
addConstraint(argumentType, ASSIGNABLE_TO, parameter.type)
return substitute(signature.returnType, solveConstraints())
solveConstraints():
gather lower and upper bounds for each type variable
merge compatible candidates
reject contradictory bounds
apply defaults for unresolved variables
return substitutions
TypeScript thực tế phức tạp hơn nhiều: compiler thử các overload candidate, có thể trì hoãn conditional type, đồng thời dùng độ ưu tiên và candidate đã cố định trong inference. Dù vậy, “thu thập bằng chứng, tạo ràng buộc, giải rồi kiểm tra” vẫn là mental model hữu ích.
Note
Inference và checking liên quan nhưng không đồng nhất. Checker có thể suy luận thành công một candidate type rồi vẫn từ chối chương trình vì candidate đó không assignable tại nơi nó được sử dụng.
Suy luận cục bộ và contextual typing đi ngược chiều nhau
Suy luận cục bộ thường đi từ expression lên trên. Initializer quyết định kiểu của variable, còn argument expression đóng góp candidate cho generic parameter. Cơ chế này đôi khi được gọi là synthesis: expression trả lời câu hỏi “Tôi có kiểu gì?”
const port = 8080; // 8080, được giữ vì binding không thể đổi
let retries = 3; // number, được widen vì binding có thể đổi
const server = {
host: "localhost",
port,
};
// { host: string; port: number }
Contextual typing đi từ trên xuống. Một đích đến đã biết cho expression biết nó được kỳ vọng mang kiểu nào. Parameter của arrow function dưới đây không được suy luận từ body; nó đến từ vị trí callback trong Array.prototype.map.
const lengths = ["Ada", "Grace"].map((name) => name.length);
// name: string; lengths: number[]
type Handler = (event: { kind: "save"; path: string }) => void;
const onSave: Handler = (event) => {
console.log(event.path); // event nhận kiểu từ context
};
Sự trao đổi hai chiều này gần với bidirectional type checking: một số expression tổng hợp kiểu, số khác được kiểm tra dựa trên kiểu kỳ vọng. TypeScript không phải một bidirectional calculus đúng theo giáo trình, nhưng mô hình này giải thích vì sao tách cùng callback ra khỏi context lại làm kết quả thay đổi.
const detached = (value) => value.length;
// Lỗi với noImplicitAny: parameter 'value' ngầm có kiểu 'any'.
const attached: (value: string) => number = (value) => value.length;
Riêng body không chứng minh được value là string. Bất kỳ object nào có property length kiểu number cũng dùng được, và TypeScript chủ động không tìm khắp vũ trụ structural type để tự sáng tạo parameter trừu tượng nhất. Annotation hoặc context tại call site cung cấp chiều thông tin còn thiếu.
| Tình huống | Chiều bằng chứng | Kết quả thường gặp |
|---|---|---|
const x = expression |
Expression đến binding | Tổng hợp kiểu từ initializer |
| Callback argument | Parameter đến callback | Parameter nhận contextual type |
| Generic call | Argument đến type parameter | Kết hợp candidate thành phép thế |
| Return annotation | Signature đến function body | Kiểm tra expression trả về theo đích |
Expression với satisfies |
Expression cộng kiểm tra đích | Giữ kiểu suy luận, kiểm tra tương thích |
Ràng buộc và unification hữu ích, nhưng assignability đổi luật chơi
Trong inference kiểu Hindley-Milner, unification cố làm cho hai biểu thức kiểu bằng nhau. Unify Array<alpha> với Array<string> cho kết quả alpha = string. Thuật toán kinh điển phân rã type constructor theo cách đệ quy, bind variable và thực hiện occurs check để một kiểu chưa biết không thể chứa chính nó.
unify(left, right):
left = applySubstitutions(left)
right = applySubstitutions(right)
if left equals right: return
if left is type variable and left does not occur in right: bind(left, right)
else if right is type variable and right does not occur in left: bind(right, left)
else if constructors match:
unify each corresponding type argument
else:
fail with incompatible types
TypeScript không thể chỉ dựa vào quan hệ bằng nhau. Structural type system của nó liên tục hỏi một kiểu có assignable cho kiểu khác hay không. Object có thể chứa property dư, union đưa vào nhiều constituent khả dĩ, còn parameter và return của function tạo bound theo những chiều khác nhau. Thay vì chỉ giải alpha = T, checker thường tích lũy lower candidate và upper constraint.
function choose<T>(left: T, right: T): T {
return Math.random() < 0.5 ? left : right;
}
const value = choose({ id: 1 }, { id: 2, label: "two" });
// T phải chứa được kiểu của cả hai argument.
Cách kết hợp candidate nhạy với context và phiên bản compiler; nó không hứa tính ra một kiểu “tốt nhất” duy nhất theo toán học. Điểm quan trọng là cả hai argument cùng ràng buộc một T. Object type kết quả phải an toàn đối với value mà function tuyên bố sẽ trả về.
Warning
Đừng dùng kiểu suy luận mà một phiên bản TypeScript hiển thị làm định dạng tuần tự hóa hoặc public contract. Heuristic inference có thể được cải thiện giữa các bản phát hành, trong khi kiểu API được annotation rõ ràng giữ ổn định hơn.
Widening quyết định literal là sự thật hay khả năng
Literal "ready" có thể mang nghĩa đúng một value, hoặc đại diện cho bất kỳ string nào trong tương lai. TypeScript dùng mutability và context để chọn giữa hai cách hiểu. Variable khai báo bằng const giữ primitive literal, nhưng property của object nhìn chung vẫn mutable dù binding của object là const, vì vậy value của property được widen.
const status = "ready"; // "ready"
let mutableStatus = "ready"; // string
const job = { status: "ready" }; // { status: string }
const frozenJob = { status: "ready" } as const;
// { readonly status: "ready" }
Widening ngăn độ đặc hiệu ngoài ý muốn. Nếu let mutableStatus có kiểu "ready", phép gán "running" ở dòng kế tiếp sẽ thất bại dù mutation chính là lý do let tồn tại. Ngược lại, discriminated union cần literal discriminant, nên as const, annotation tường minh hoặc contextual typing có thể bảo toàn chúng.
Operator satisfies tách inference khỏi validation. Nó kiểm tra value với một target mà không thay kiểu suy luận của value bằng target đó.
type RouteTable = Record<string, { method: "GET" | "POST"; path: string }>;
const routes = {
health: { method: "GET", path: "/health" },
submit: { method: "POST", path: "/submit" },
} satisfies RouteTable;
routes.health.method; // "GET"
routes.missing; // Lỗi: inferred object không có property này
Annotation như const routes: RouteTable = ... chỉ bộc lộ góc nhìn Record rộng, nên cho phép tra cứu bằng string bất kỳ và làm các key đã biết mất độ chính xác. satisfies kiểm tra contract nhưng vẫn giữ tập key cụ thể.
Array gặp bài toán “best common type” tương tự. Với [1, "two"], checker tìm một kiểu mà mọi element đều assignable vào đó và tạo (string | number)[]. Tuple context hoặc as const thay vào đó bảo toàn vị trí lẫn literal thành readonly [1, "two"].
Suy luận generic giải quan hệ, không chỉ điền chỗ trống
Generic parameter có giá trị khi nó nối nhiều vị trí với nhau. Trong first<T>(items: readonly T[]): T | undefined, argument đóng góp candidate cho T, rồi phép thế được chọn chảy sang return type.
function first<T>(items: readonly T[]): T | undefined {
return items[0];
}
const firstName = first(["Ada", "Grace"]); // string | undefined
function getProperty<ObjectType, Key extends keyof ObjectType>(
object: ObjectType,
key: Key,
): ObjectType[Key] {
return object[key];
}
const user = { id: 42, name: "Lin" };
const userName = getProperty(user, "name"); // string
// getProperty(user, "email"); // Lỗi: "email" không thuộc keyof typeof user
Constraint Key extends keyof ObjectType là upper bound: key được suy luận phải thuộc key union của object. Return type sau đó indexed access bằng key đã suy luận. Quá trình này không chỉ điền từng ô trống độc lập; solver bảo toàn một quan hệ xuyên qua vị trí argument và result.
Inference có thể thất bại khi bằng chứng chỉ theo những hướng cạnh tranh hoặc chỉ xuất hiện ở return position. TypeScript không thể suy luận T từ lời gọi thiếu context đến make<T>(): T, vì không argument nào cung cấp candidate. Một default hoặc type argument tường minh sẽ trung thực hơn:
function makeSet<Element = string>(): Set<Element> {
return new Set<Element>();
}
const names = makeSet(); // Set<string>
const ids = makeSet<number>(); // Set<number>
Conditional type có infer dùng cơ chế khác nhưng liên quan: nó pattern-match một kiểu và đưa kiểu chưa biết vào bên trong cấu trúc được match.
type AwaitedValue<Value> = Value extends PromiseLike<infer Inner>
? AwaitedValue<Inner>
: Value;
type Result = AwaitedValue<Promise<Promise<number>>>; // number
Flow narrowing và variance thêm thời gian lẫn phương hướng
Kiểu được suy luận tại declaration chưa phải toàn bộ câu chuyện. TypeScript xây control-flow graph và tính một kiểu đã narrow tại mỗi program point có thể đi tới. Guard, assignment, early return, kiểm tra discriminant và assertion function đều ảnh hưởng flow type đó.
type Response =
| { ok: true; value: string }
| { ok: false; error: Error };
function unwrap(response: Response): string {
if (!response.ok) {
throw response.error;
}
return response.value; // tại đây response là variant thành công
}
function format(value: string | number | null): string {
if (value === null) return "none";
if (typeof value === "number") return value.toFixed(2);
return value.toUpperCase();
}
Đây là refinement, không phải việc viết lại vĩnh viễn declared type của variable. Một assignment về sau có thể làm flow state hiện tại rộng trở lại. Narrowing cũng có thể bị vô hiệu quanh mutation hoặc aliasing khi checker không chứng minh được một sự thật còn giữ nguyên.
Variance mô tả assignability thay đổi thế nào qua một type constructor. Producer trả về T có tính covariance tự nhiên: producer của Dog có thể dùng ở nơi cần producer của Animal. Consumer nhận T có tính contravariance khi bật strictFunctionTypes: consumer xử lý được mọi Animal có thể thay an toàn cho consumer chỉ bị yêu cầu xử lý Dog.
type Producer<T> = () => T;
type Consumer<T> = (value: T) => void;
class Animal {
move(): void {}
}
class Dog extends Animal {
bark(): void {}
}
const dogProducer: Producer<Dog> = () => new Dog();
const animalProducer: Producer<Animal> = dogProducer;
const animalConsumer: Consumer<Animal> = (animal) => animal.move();
const dogConsumer: Consumer<Dog> = animalConsumer;
Khi type parameter xuất hiện ở cả input lẫn output position, trên thực tế nó thường invariant. Variance tác động đến inference vì vị trí input và output đóng góp bound theo những chiều khác nhau. TypeScript còn có ngoại lệ tương thích, đáng chú ý là parameter bivariance cho method, vì vậy nên xem variance là quy tắc assignability cần kiểm tra thay vì khẩu hiệu để học thuộc.
Tip
Hãy bật strict, đặc biệt là noImplicitAny và strictFunctionTypes. Inference hữu ích nhất khi compiler báo nơi thiếu bằng chứng thay vì âm thầm thay một quan hệ chưa biết bằng any.
Giới hạn, annotation và những điều cần nhớ
Inference được thiết kế có tính cục bộ. TypeScript không chứng minh định lý trên toàn chương trình, không suy luận đơn vị ngữ nghĩa như mét với giây, và không thể đọc ý định implementation khi ý định ấy vắng mặt trong type. Dynamic property name, mutation qua alias, conditional type phức tạp, API có nhiều overload và value đi vào từ any đều có thể xóa bằng chứng hữu ích. Hiệu năng compiler cũng quan trọng: một đáp án chính xác hơn về lý thuyết có thể bị bỏ qua nếu việc tìm nó khiến phản hồi trong editor tốn kém hoặc thiếu ổn định.
Vì vậy, annotation không phải dấu hiệu thất bại. Hãy dùng chúng tại module boundary, exported function, recursive definition, collection rỗng, callback declaration không có context và nơi abstraction mong muốn rộng hơn implementation. Để inference xử lý local hiển nhiên, bởi lặp lại number bên cạnh 1 + 2 chỉ tạo nhiễu và có thể che mất lỗi khi refactor.
Mental model thực dụng có thể tóm gọn như sau:
- Expression cung cấp local candidate; expected type cung cấp context theo chiều ngược lại.
- Constraint nối candidate bằng assignability, còn unification chỉ giải thích phần bài toán có hình dạng quan hệ bằng nhau.
- Widening cân bằng độ chính xác của literal với mutation dự kiến;
as constvàsatisfiescho phép ta chủ động lựa chọn. - Generic inference mạnh nhất khi type parameter nối input với output và constraint diễn tả quan hệ thực.
- Control-flow analysis refine kiểu tại từng program point; nó không thay đổi declared contract mãi mãi.
- Variance quyết định chiều thay thế an toàn, do đó quyết định các bound được thu thập cho generic parameter.
- Explicit annotation thuộc về nơi thiếu bằng chứng, nơi public intent quan trọng hoặc stable boundary đáng giá hơn độ chính xác cục bộ tối đa.
Khi nhìn inference như luồng thông tin có ràng buộc thay vì phép màu, thất bại của nó trở thành vấn đề có thể xử lý. Hãy hỏi bằng chứng phải đến từ đâu, nó di chuyển theo chiều nào, widening có làm mất độ chính xác không và mutation có vô hiệu một sự thật hay không. Những câu hỏi ấy thường dẫn đến một annotation nhỏ, hoặc tốt hơn nữa, một API có type khiến quan hệ dự định có thể được suy luận tự nhiên.