TypeScript can catch an invalid method call before your program runs, even when you never wrote a type annotation for the variable. Leaving types out is convenient, but what evidence does the compiler use to recover them?
TypeScript adds type syntax and checking to JavaScript. A type describes information the checker can use to decide which operations are valid for a value, such as treating it as a string or a number. In const age: number = 20, the : number is a type annotation explicitly describing age. Official introduction to variable annotations
The TypeScript compiler reads source code, checks type relationships, and can emit JavaScript. The component responsible for examining types is the type checker. This article reads that component in TypeScript 7's Go implementation. The TypeScript examples are the code being checked; the Go excerpts are the compiler code doing the checking.
You can start with familiarity with variables, functions, and return. Compiler terminology and the Go notation needed for each excerpt are introduced along the way. Rather than cover every form of inference, we will focus on one question: how does the compiler determine the type a function returns?
I am not part of the TypeScript team. This is an independent technical reading of the publicly available source code and documentation, not an official explanation of the implementation or its design decisions.
Source and license note
The source comes from Microsoft's public microsoft/TypeScript repository, primarily its Go compiler's checker.go and types.go. References are pinned to revision f29aeb9, with line links beside the implementation discussion.
The source is licensed under the Apache License, Version 2.0. Blocks labeled “upstream excerpt” quote that repository, with unrelated portions omitted and explanatory comments added. Diagrams, explanations, and learning examples in TypeScript were created for this article. The full license is available in LICENSE.txt; third-party attribution notices are available in NOTICE.txt.
Start with code that has no type annotations
There is no return type annotation here. There is no type annotation on user, either. Yet TypeScript knows that user.name is a string.
function getUser() {
return { name: "Alice", age: 20 };
}
const user = getUser();
// name is a string, so this string method is available.
user.name.toUpperCase();
Here, getUser returns an object with two properties, name and age. The statement const user = getUser() calls the function and stores its result in user. The last line reads the name through user.name and calls toUpperCase() to convert that string to uppercase.
How does it know? The compiler does not run getUser() and inspect the result. It examines expressions and declarations in the source and constructs type information. This is type inference. The TypeScript Handbook describes return type inference as deriving a function's return type from its return statements.
This series reads widely used tools to find implementation patterns we can apply in our own software. This installment follows return type inference through TypeScript 7's Go compiler. You do not need to have read the previous article about checker pools: this one starts with the function above.
Four objects to keep apart
Before following function calls inside the compiler, we need a small map.
getUser() in the source
|
+-- AST The syntax of calling the name getUser
+-- Symbol Information about the declaration that name refers to
+-- Type The type of the value being examined
+-- Signature Parameters and return information for a function call
+-- resolvedReturnType -> computed return Type
This is a map of responsibilities, not a diagram of four physically nested objects.
An AST, or abstract syntax tree, describes source structure: a function declaration, a return statement, an object literal. Recognizing that structure and determining its types are different jobs.
A Symbol connects a name to declaration information. The same text, getUser, can refer to different declarations in different scopes. The compiler must resolve the name at its particular location.
A Type represents information such as string, number, or an object type. A Signature describes a callable shape, including parameters and return information. Function types can have overloads, so a function type and a Signature are not interchangeable concepts. Our example has neither overloads nor parameters.
These roles appear in name resolution, call resolution, and the Signature definition.
From the name to the function being called
For getUser(), the compiler first needs to examine the expression being called: getUser. Inside resolveCallExpression, we find this line:
// Upstream excerpt: node represents the whole getUser() call.
// node.Expression() represents the getUser expression being called.
funcType := c.checkExpression(node.Expression())
c is a pointer to the Checker doing the work. The result is a *Type, a pointer to type information, not the runtime result of calling the function. In Go, := declares a variable and assigns its initial value. Source
A name such as getUser, used for a function or variable, is an identifier. Expression checking dispatches according to the kind of expression; checkIdentifier handles identifiers. Its role is to determine what declaration the name refers to and what type of value it represents. Identifier checking
To obtain the declaration information, checkIdentifier calls getResolvedSymbol. On the first request for this identifier's AST node, getResolvedSymbol uses resolveName to determine which declaration getUser refers to at this location. It stores a reference to the resulting Symbol in links.resolvedSymbol, associated with that node. Resolution and storage
Later requests in the same checker for the same identifier AST node return the stored reference. This is not a cache that treats every occurrence of the text getUser as identical. An identifier at another location is a different node, and its scope may lead to a different declaration.
The binder prepares the declaration information used by this lookup. Function declarations enter that process through bindFunctionDeclaration. Registering a declaration and resolving a name at its use site are the two sides of this connection. Function binding
At this point, we can distinguish “which function is this?” from “what does it return?” Answering the first question does not require that the second answer already be computed.
A Signature can exist before its return type is known
Here are the fields we need from the actual Signature structure:
// Upstream excerpt: other fields omitted.
type Signature struct {
declaration *ast.Node // Points to the function declaration.
parameters []*ast.Symbol // A slice of parameter symbol pointers.
resolvedReturnType *Type // Computed return type; nil if not computed.
}
In this example, declaration points to the AST node representing function getUser() { ... }. It refers to an in-memory node created by parsing the source, not directly to a line number or a position in the source text. The node provides access to the parameters and body. The Signature does not contain a copy of the function's source. []*ast.Symbol is a slice of pointers to parameter symbols. Our getUser has no parameters. nil means that no type pointer has been stored in the return-type field yet. Structure definition
getSignatureFromDeclaration is the entry point for obtaining a declaration's Signature. On the first request, it collects parameter information and calls newSignature, passing nil for the as-yet-uncomputed resolvedReturnType. It stores the returned Signature pointer in links.resolvedSignature, associated with the declaration node. Later requests for the same declaration in the same checker return that stored pointer. Creation and storage, subsequent reuse
There is not one Signature for the entire compiler. For ordinary function declarations like this example, the declarations of getUser and getCount have separate Signatures. The beginning of getSignatureFromDeclaration shows how they are associated:
// Upstream excerpt: declaration is the function declaration being examined.
// Obtain the storage associated with this declaration node.
links := c.signatureLinks.Get(declaration)
if links.resolvedSignature != nil {
// Return the Signature pointer previously stored for this declaration.
return links.resolvedSignature
}
Passing the node to Get(declaration) selects declaration-specific information, not one shared slot for every function. When creation is needed, newSignature allocates a new Signature through sig := c.signatureArena.New(). Declaration-specific lookup, allocation
Two ordinary function declarations in the same checker
getUser declaration -> its links -> resolvedSignature -> Signature A
getCount declaration -> its links -> resolvedSignature -> Signature B
Request getUser again -> return the stored Signature A
The sig in getReturnTypeOfSignature(sig *Signature) is a parameter name. It points to A in a call that receives A, and B in a call that receives B. Each call receives one of the available Signatures through that parameter. This describes our simple declarations, not a universal one-Signature-per-function rule: generic instantiation and other features can create additional Signatures.
newSignature allocates a Signature from the checker's signatureArena, stores the supplied declaration-node pointer and parameter information, and returns a pointer to that Signature. The field has type core.Arena[Signature]. Field definition, factory implementation
This is the same small core.Arena[T] implementation discussed in the earlier article about AST memory layout. It groups values of one type in Go slices, and New() returns a pointer to one element. Here, the type argument T is Signature. The AST allocator uses the same implementation, but the allocated objects and the arena instances are different. Arena implementation
For allocation details and pointer validity, see TypeScript 7 Arena Allocation and AST Memory Layout. Here, the important result is a Signature that refers to the function declaration and has no computed return type yet.
New Signature
declaration -> AST for function getUser() { ... }
parameters -> no parameters
resolvedReturnType -> nil
After return type computation
declaration -> the same declaration AST
resolvedReturnType -> Type representing { name: string; age: number }
Compute the return type when it is needed
checkCallExpression obtains the call's Signature through getResolvedSignature, then passes that Signature to getReturnTypeOfSignature. Call site
The return-type function starts with a cache lookup:
// Upstream excerpt: return the existing Type pointer if already computed.
if sig.resolvedReturnType != nil {
return sig.resolvedReturnType
}
Here, sig points to the Signature being examined. If the field is empty, more work is needed. The ordinary declaration branch first looks for an explicit return type annotation, then examines the body if there is no annotation:
// Upstream excerpt: part of the default branch for ordinary declarations.
t = c.getReturnTypeFromAnnotation(sig.declaration)
if t == nil {
if !ast.NodeIsMissing(sig.declaration.Body()) {
t = c.getReturnTypeFromBody(sig.declaration, CheckModeNormal)
} else {
t = c.anyType
}
}
t is a local *Type variable holding the result under computation. sig.declaration is the declaration saved earlier; Body() retrieves its body. CheckModeNormal selects ordinary checking. Our example has a body but no return annotation, so it takes the getReturnTypeFromBody path. Complete function
The real function also handles generic instantiation, composite signatures, optional calls, and circular resolution. This excerpt is not a replacement implementation. Nor does finding an annotation mean the compiler skips checking whether the body satisfies it: obtaining a return type and validating the body are different responsibilities.
Build the type from return expressions
For an ordinary block-bodied function, getReturnTypeFromBody calls checkAndAggregateReturnExpressionTypes. That helper visits return statements, extracts their expressions, checks them with checkExpressionCachedEx, and collects their types without duplicate entries. Return-expression collection
Our return expression is { name: "Alice", age: 20 }.
return { name: "Alice", age: 20 }
| |
| +-- numeric expression
+-------------- string expression
v
construct object property types
v
adjust the inferred return type
v
{ name: string; age: number }
Why is name a string rather than the type "Alice"? TypeScript also has literal types, which describe specific values. But this object's properties are mutable:
function getUser() {
return { name: "Alice", age: 20 };
}
const user = getUser();
// const prevents reassigning user, not changing the object's properties.
user.name = "Bob";
user.age = 21;
Here, specific literal types are widened to string and number. This is literal widening. The implementation includes return-type widening and property-level widening for object literals. Annotations, context, and const assertions affect the result; literals do not always become their wider primitive types. Return-type adjustment, object-property widening
In getUser(), the type of name was string, allowing other strings such as "Bob". TypeScript can also express a type that accepts only the string "Alice". When "Alice" appears in a type annotation, it specifies that single-value type rather than a runtime string expression.
Add as const to preserve these specific types in the returned object:
function getFixedUser() {
// Keep name as the type "Alice" and age as the type 20, without widening.
// The properties of this object also become readonly.
return { name: "Alice", age: 20 } as const;
}
const user = getFixedUser();
// : "Alice" means this variable accepts only the string "Alice".
// user.name has that type, so the assignment is valid.
const literalName: "Alice" = user.name;
The last line declares literalName and assigns user.name to it. The literalName: "Alice" part specifies the accepted type; = user.name supplies the value. It neither converts the value nor checks it at runtime. The compiler checks that the type on the right fits the annotation on the left.
Compare the two examples:
getUser() without as const
|
getFixedUser() with as const
|
|
|---|---|---|
Type of name
|
string |
"Alice" |
Type of age
|
number |
20 |
user.name = "Bob" |
Passes type checking | Type error because the property is readonly |
Readonly here is a TypeScript checking constraint: as const does not freeze the object at runtime. Also, const user prevents reassignment of the variable; it serves a different purpose from the as const assertion on the object. Official const assertion documentation
Inference determines not only that a value is a string, but also whether its specific value remains part of the type.
What if there are two return statements?
Consider a function whose return value depends on a condition:
function getValue(asText: boolean) {
if (asText) {
return "ready";
}
return 200;
}
const value = getValue(true);
// The return type is "ready" | 200. Narrow it before calling a string method.
if (typeof value === "string") {
value.toUpperCase();
}
The return type is the union "ready" | 200: a value can have either type. These literal types are retained, unlike the mutable object properties in the earlier example. Passing true does not specialize this ordinary function's return type to "ready" at that call site. The call uses the return type inferred from the function body.
The helper we just followed returns ([]*Type, bool): the collected types and a flag used for cases that do not return a normal value. After handling that flag and empty return-type collections, its caller combines the collected types with getUnionTypeEx. Ordinary function-body branch
Reading only the first return statement would miss valid possibilities. Simulating the function with each call's runtime arguments is unnecessary, too. The compiler combines possible types, and the caller can narrow the result before using operations that require one particular type.
Where the inferred type is stored
At the end of getReturnTypeOfSignature, we find:
// Upstream excerpt: preserve a result already assigned during resolution.
if sig.resolvedReturnType == nil {
sig.resolvedReturnType = t
}
return sig.resolvedReturnType
The destination is the field on the same Signature we started with. The returned pointer is the one stored in that field. The next request for this Signature's return type can take the early return at the top of the function. Storage and return
First request for the same Signature
nil -> compute from the body -> store resolvedReturnType -> return it
Request after computation
return resolvedReturnType
This combines lazy computation with memoization: compute information when needed, then retain the result. Ordinary checking may already have requested the return type before the compiler reaches a call you are looking at. This diagram describes a dependency between questions, not a promise about the compiler's global traversal order.
These Signatures are created on the checker side. The cache is not a promise to share one pointer across different checkers or future builds. It is a concrete example of the reusable semantic state discussed in the checker-pool article.
What if reassignment changes the type at a later location?
Caching alone might suggest that once a variable is treated as a number, every later use must treat it as a number too. But this code passes TypeScript checking:
// This variable accepts either a number or a string.
let value: number | string = 20;
// The assignment leading to this location supplied a number.
value.toFixed(0);
value = "hello";
// At this location, the assignment supplied a string instead.
value.toUpperCase();
Calling toFixed does not change the type of value. Initialization supplies a number, and the later assignment supplies a string. We need to distinguish the type a variable accepts from the type known at a particular reference to that variable.
| Information | Type in this example | Purpose |
|---|---|---|
| Declared type | `number \ | string` |
value before toFixed
|
number |
Determine valid operations at this location |
value after reassignment |
string |
Determine valid operations at this location |
The declared type remains number | string, so assigning a string is allowed. Assigning true would be a TypeScript error because it is outside that union; no ESLint rule is required. The compiler analyzes assignments and branches to narrow the type at individual locations through control flow analysis. Official explanation of assignments and narrowing
The Go implementation separates these concerns too. Inside checkIdentifier, we find:
// Upstream excerpt: obtain the type accounting for flow to this reference.
flowType = c.getFlowTypeOfReferenceEx(node, t, initialType, flowContainer, nil)
node identifies the particular reference to value being checked. t is the variable's underlying type, number | string in this example. initialType supplies the starting type for flow analysis, and flowContainer identifies its enclosing analysis scope. The final nil lets the function obtain the starting flow node from the reference rather than receiving one explicitly. The returned flowType is a *Type for this location. This call does not overwrite sig.resolvedReturnType. Call site, reference-based analysis
getFlowTypeOfReferenceEx follows the flow associated with the reference, accounting for assignments and branches. getTypeAtFlowAssignment checks whether an assignment targets the reference being analyzed and, for a declared union, reduces the type using the assigned type. This distinguishes the number assignment from the later string assignment. Assignment handling
What does this function return?
Signature -> resolvedReturnType
What type is available at this variable reference?
Variable's underlying type + assignments and branches -> type at this location
Reusing type information does not mean applying the first observed type of a variable to all later references. The information must match the subject and conditions of the query. Flow analysis also happens during compilation: it examines the source rather than monitoring values while the program runs.
Back to user.name.toUpperCase
We can now explain the opening example as a sequence of type questions:
getUser()
-> resolve the call's Signature
-> obtain { name: string; age: number }
const user = getUser()
-> derive the variable's type from its initializer
user.name
-> look up name on the object's type
-> string
user.name.toUpperCase()
-> check the call to the method available on string
The checker also retains computed variable types. Property access starts by checking the left-hand expression's type, then examines the requested property. Variable-type cache, property-access entry point
“Inference” covers several cooperating operations. Resolving a name, finding an expression's type, and looking up a property answer different questions. Keeping those questions separate makes the implementation easier to follow.
What happens when the source code is edited?
The previous example contained a reassignment within one version of the source. Now consider editing the function and checking the changed source again:
function getUser() {
// Change the number 20 to the string "20".
return { name: "Alice", age: "20" };
}
const user = getUser();
// @ts-expect-error age is now string, so the numeric method is unavailable.
user.age.toFixed(0);
@ts-expect-error is a comment that the TypeScript compiler interprets specially. The next line is still type-checked, but its type errors are suppressed. If that line has no type error, the compiler instead reports an unused @ts-expect-error directive.
With the directive present, the error about toFixed is suppressed and this example passes type checking. Delete the entire // @ts-expect-error ... line to see TS2339: toFixed does not exist on string.
In either case, user.age remains a string and the method call remains invalid. The directive does not fix the code; it lets the example check that an expected type error occurs. Executing the emitted JavaScript would still produce a runtime error at this call.
Does the previously cached number remain? We need to separate reusing a computed type inside a checker from deciding which previous build results remain valid after an edit. The nil check itself does not detect file changes.
The incremental builder tracks a file version, derived from its input, and a signature, which can be derived from declaration output. They help distinguish changes to the input from changes to the externally visible shape. This lowercase signature is a comparison string, not the Go Signature structure representing a function call. Change detection, shape-signature computation
age: 20 -> age: 21
Source changes; this example's public age type remains number.
age: 20 -> age: "20"
Source changes; its public age type changes from number to string.
That difference helps the builder find affected files. Declaration files, global declarations, compiler options, and saved state also affect its decisions. It is not a guarantee that unchanged public types prevent every dependent recheck. The relevant lesson here is that a cached inference result needs a separate mechanism to manage where it remains valid.
What to borrow for application code
The implementation separates identifying an object, computing information about it, and retaining the result on an appropriate owner. That pattern applies to parsed configuration, document analysis, and other derived information.
Lazy computation can avoid work on information nobody requests. Memoization can avoid repeating the same work. The trade-off is that we must handle circular computation and changes to the input. TypeScript's resolution checks and incremental builder provide concrete examples of those additional responsibilities.
The compiler knows that user.name is a string because name resolution, return-type computation, and property lookup connect the necessary facts. Understanding those steps explains both why annotations can be omitted and what information the compiler retains to answer later questions.
Example verification
The TypeScript examples were checked in strict mode with TypeScript 5.7.2, including declaration output for inferred types. Removing the expected-error comment produces TS2339 for toFixed. The union reassignment example was also emitted as JavaScript and executed with Node.js. Additional checks reject assigning a boolean and calling toFixed after string reassignment. The Go implementation discussion is based on the pinned source revision above; these checks are not a TypeScript 7 binary run or a performance measurement.
Related project
I develop Kinmokusei, a language with TypeScript-inspired syntax that generates Go code. Separating syntax processing from name and type handling is also a practical concern when building a language. Its documentation introduces the language and its syntax.
Top comments (0)