Name Resolution
Identifier, find the declaration, attach the symbol. Straightforward for a local variable, and genuinely hard for an overload set, a re-exported import, two glob imports that both provide the name, or a method on a receiver whose type has not been inferred yet.
How does an identifier get connected to the thing it names, and where does that get hard?
Before: an AST whose identifiers are text. After: the same tree with each identifier bound to a specific declaration — a symbol reference recorded on the node or in a side table keyed by node id. That binding is the input every later phase depends on: the type checker needs it to know what type a name has, the code generator needs it to know what to emit, and every editor feature is a query over it.
Resolution may assume that the set of candidate declarations at a program point is fully determined by the scope chain plus whatever the module system has made visible, and that this set does not change during the resolution pass — which is why declarations are collected first. It may attach a symbol to an identifier only when exactly one candidate is selected by the language's rules; where several remain, the language must define a tie-break (overload resolution, most-specific import, an explicit error for ambiguity) and the resolver must implement that rule rather than choose arbitrarily. Picking one silently is the failure that produces a program that compiles and calls the wrong function.
Key points
- Resolution turns identifier text into a reference to a specific declaration, and everything downstream reads that reference rather than the name.
- After resolution the compiler never compares identifier strings again, which is why shadowing stops being a concern for later phases.
- Overload sets break the clean layering of resolution before type checking, because choosing needs argument types.
- Imports and re-exports make visibility a graph traversal with cycles, not a walk up the enclosing text.
- Ambiguity must be an error or a defined tie-break; silently preferring a candidate produces behavior that changes when an unrelated module changes.
- Method resolution on an uninferred receiver is a genuine cycle, and every language's way of breaking it is a rule users have to learn.
- Resolution errors are the errors users hit most; naming the searched scope and suggesting the missing import is most of what makes them good.
The easy case, and what it produces
For a local variable the algorithm is the one from [[lexical-scope]]: walk outward, take the first hit, record it. The result is worth looking at, because it is the concrete artifact everything downstream reads.
The tree below is let total = n + 1; inside a function with a parameter n, after resolution. Every identifier now carries a symbol: not a name, a *reference to a declaration*. From this point the compiler never needs to compare strings again, and two identifiers that happen to spell the same are distinguishable — which is what makes shadowing a non-issue for every later phase.
Read it asNo types yet — that is the next phase, and it needs exactly this. decl#3 tells the type checker where to find n's declared type; without the symbol it would only know that some identifier spelled n appears here, which is not enough to type anything.
Where it stops being easy
Four cases account for most of the difficulty, and each breaks a different assumption of the simple algorithm.
Overload sets. In C++, Java, C# or Swift, a name does not denote one declaration but a set of them, and the choice depends on the argument types — which are the type checker's business. So resolution and type checking cannot be layered cleanly; they interleave. C++ makes this maximally hard by adding argument-dependent lookup, where the namespaces searched depend on the *types of the arguments*, so the candidate set itself is a function of the types.
Imports and re-exports. A name may be visible because this module declared it, or imported it, or imported a module that re-exports something it imported from elsewhere. Following that chain is a graph traversal with cycles, and languages must define what happens when the chain loops. The user-visible symptom of getting it wrong is a "cannot find" error for a name that is demonstrably exported.
Glob import collisions. use a::*; use b::*; where both provide foo is ambiguous. The reasonable designs are: error on the ambiguous *use* (Rust), error on the import itself, or pick by a precedence rule. The unreasonable design — silently prefer one — is the one that produces a program that works until someone adds a name to an unrelated module, at which point behavior changes with no local edit.
Method resolution before inference. x.foo() cannot be resolved until the type of x is known, and in a language with inference the type of x may depend on how it is used, which may depend on what foo resolves to. Rust breaks this by requiring enough information to be known at the point of the method call and reporting "type annotations needed" when it is not; Haskell defers via constraints; TypeScript uses contextual typing plus a defined order of inference. Every language has a rule here and every such rule is a place users get stuck.
| Case | Why the simple algorithm fails | A language's answer |
|---|---|---|
| Overload sets | A name maps to several declarations; the choice needs argument types | C++/Java/C#: overload resolution ranks candidates by conversion cost, ambiguity is an error |
| Argument-dependent lookup | The candidate set itself depends on the argument types | C++ only: namespaces of the argument types are added to the search, which is why swap(a, b) finds the right one |
| Imports and re-exports | Visibility comes from a graph, not from the enclosing text | Rust/ES modules: follow the re-export chain, detect cycles, error on unresolved |
| Glob collisions | Two equally-near candidates with no ordering between them | Rust: legal to import both, error only if the ambiguous name is *used* |
| Method on an uninferred type | Resolution needs the type; the type may need the resolution | Rust: "type annotations needed"; TypeScript: contextual typing; Haskell: defer as a class constraint |
| Traits / extension methods in scope | A method exists only if a trait or extension is imported | Rust and Swift: the import decides; the error is "method not found" with a note suggesting the import |
What good resolution errors look like
Resolution produces the errors users hit most often, so the quality of those messages is a large share of a language's perceived quality. Three things separate a good message from a bad one.
First, name the scope that was searched. "Cannot find foo in this scope" is more useful than "undefined: foo", because it tells the user which chain the compiler walked. Second, suggest: an edit distance search over the visible names catches typos, and a search over *not*-visible names catches the missing import — "a function with a similar name exists in module bar; add use bar::foo" turns a dead end into a fix.
Third, be honest about ambiguity. When several candidates match, listing them with their declaration sites is far more useful than picking one, and it is the only correct behavior when the language says the case is ambiguous. [[suggested-fixes]] is where this is treated properly; the point here is that these messages are resolution's output as much as the symbols are.
- Name the searched scope, not just the missing name.
- Search visible names by edit distance for typos.
- Search *invisible* names too — the most common cause of "cannot find" is a missing import, not a typo.
- On ambiguity, list every candidate with its declaration site rather than choosing.
- Attach an error symbol rather than aborting, so type checking can continue and report its own findings in the same run.
How it works
The steps, in the order the compiler takes them.
- Collect declarations first, so that forward references and mutually recursive definitions can resolve — see
[[declaration-order]]. - Build the visible set at each point: the scope chain, plus names introduced by imports, plus any names the language makes visible implicitly (a prelude, an enclosing type's members, traits in scope).
- For each identifier, gather candidates from the nearest scope outward, stopping at the first scope that provides any.
- If exactly one candidate remains, attach it. If several, apply the language's tie-break — overload ranking, most-specific import — and report ambiguity if none applies.
- For a member access, wait until the receiver's type is known, then search that type's members plus any extension or trait methods currently in scope.
- On failure, attach an error symbol so later phases continue, and emit a diagnostic that names the searched scope and offers the nearest visible and invisible candidates.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- A glob-import collision is resolved by preferring whichever module was imported first. The program calls a different function after an unrelated dependency adds a name, and nothing in the diff explains it.
- The resolver aborts on the first unresolved name, so a file with a missing import reports one error, and each fix reveals the next. A three-import mistake takes three build cycles.
- An overload is selected using declared parameter types rather than the language's conversion ranking, and a call that should have chosen the
doubleoverload chooses theintone. The program compiles and quietly truncates. - A re-export cycle is not detected and the resolver recurses until it overflows the stack. The compiler crashes with no diagnostic and the user's only clue is which file they last edited.
- Method resolution runs before inference on a receiver whose type is not yet determined, and the compiler reports "no method named
foo" on a type it names as_. The message is technically accurate and completely unactionable. - The resolved symbol is recorded on the node but a later transformation clones the node without it, and the type checker sees an unresolved identifier in code the resolver definitely handled.
When it helps
- Everything downstream: types, code generation, dead-code analysis and linking all consume the symbol, not the name.
- Editor features — go-to-definition is the recorded symbol, find-all-references is the reverse index, and rename is that index plus the spans.
- Detecting the largest class of user error early: misspelled names, missing imports, calls to things that do not exist.
When it hurts
- Languages with heavy overloading plus inference, where resolution and type checking become one large mutually recursive problem that is hard to explain and hard to make fast.
- Dynamic languages, where much of it cannot be done statically at all:
getattr(obj, name)andobj[key]()are resolved at runtime, and any static answer is an approximation a linter offers rather than a fact. - Very large dependency graphs, where computing visibility across thousands of modules dominates the frontend and forces per-module caching — see
[[interface-files]].
What it costs
Every one of these is paid by something.
- Overloading buys natural APIs where one name covers several types, and costs a resolution algorithm entangled with type checking, ambiguity errors users find opaque, and a real compile-time bill in languages that combine it with templates.
- Glob imports buy convenience and cost stability: what a name resolves to now depends on the contents of another module, which can change without any edit in your file.
- Attaching error symbols and continuing buys complete diagnostics in one run and costs downstream complexity, since every later phase must handle an error symbol without producing a second wave of nonsense.
- Caching resolution per module buys incremental rebuilds and costs an invalidation problem: a change to a re-export can change resolution in a module that did not change at all.
- Rich "did you mean" suggestions buy a much better first experience and cost a similarity search over the visible and invisible name sets on every failure, which is only cheap because failures are rare.
What else you could do
What a different compiler or language does instead, and when that is better.
- Resolve at runtime, as Python and JavaScript do for globals and attributes. Nothing to get wrong statically, everything deferred to execution, and
[[inline-caches]]become necessary to make it fast. - Forbid overloading entirely, as Go and Rust largely do for free functions. Resolution stays a clean layer before type checking, and the language pays in API ergonomics —
strconv.Itoaandstrconv.FormatIntrather than one name. - Require every name to be qualified, with no glob imports and no implicit prelude. Maximally explicit, maximally verbose, and every "cannot find" becomes a typo rather than a missing import.
- Resolve by unique identity rather than by name — a content-addressed language such as Unison, where a definition is referenced by the hash of its implementation and renaming is metadata. Eliminates the entire ambiguity class and requires tooling to be usable at all.
See it for yourself
The flag, dump or tool that shows you this directly.
- Rust:
rustc --explain E0425for the unresolved-name error, and rust-analyzer's go-to-definition, which is the recorded symbol surfaced directly. - C++:
clang++ -Xclang -ast-dumpshows the resolvedDeclRefExprfor each use with the declaration it points at; for overloads,-fno-elide-constructorsplus reading the dump shows which candidate won. - JavaScript:
node --experimental-vm-modulesaside, the clearest view is a bundler's module graph —esbuild --analyzeor Rollup's output shows how re-export chains were followed. - Python:
python -X importtime script.pyshows the import graph being walked;symtableshows how each name in a function was classified before any runtime lookup happens. - Our scope viewer at
/compilers/scopeshighlights, for any identifier, the scopes searched and the declaration selected.
Plausible wrong readings
Stated the way a confident engineer states them.
- "Resolution is just a lookup in a table." It is for locals. For overloads it needs types, for imports it needs a module graph, and for methods it needs inference to have already happened.
- "An unresolved name means a typo." The most common cause in a module system is a missing import, which is why good compilers search names that are *not* in scope as well as names that are.
- "If two imports provide the same name, the compiler picks the better one." Only if the language defines "better". Where it does not, the correct behavior is an error, and a compiler that picks silently has introduced a bug you will meet later.
- "Resolution finishes before type checking." In a language with overloading or methods, they interleave, and the ordering rules are a specified part of the language.
Misconceptions
The claim, and what is actually true.
Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
Resolution answers "which one" for every name. For a local variable it is the walk outward through enclosing scopes. The answer gets recorded on the identifier, and from then on the compiler works with a reference to a declaration rather than with text — which is why two variables named x never get confused later.
practical
When a name will not resolve, check in this order: is it spelled right, is it imported, is it public where it is defined, and — in Rust or Swift — is the trait or extension that provides it in scope. That order matches the frequency of the causes. When the message says the receiver has type _ or the compiler asks for annotations, it is not a resolution failure at all: inference has not produced a type yet, and adding one annotation usually unblocks everything downstream of it.
advanced
The architectural question is whether resolution can be a layer or must be a fixed point. Languages without overloading or methods-on-inferred-types can run resolution to completion and hand a fully resolved tree to the type checker, which keeps both phases simple and makes each independently cacheable. Languages with either one cannot: the two phases are mutually dependent, and the implementation becomes a constraint solver with a defined order for breaking cycles. That single design decision explains a great deal about why some compilers are fast and predictable and others have compile times that surprise their own authors.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
If you were asked this in an interview
- Walk through resolving a local variable, then say what changes when the name is an overloaded function.
- Two glob imports both provide
foo. What should the compiler do, and what is wrong with picking one? x.foo()where the type ofxis being inferred. Why is this hard, and how do languages break the cycle?- What makes a good "cannot find name" error message? Name three things beyond the name itself.
Connections
- Programming Languages & Runtime Internals — Dynamic attribute and method lookup at runtime, and the caches that make it fastWhat a static resolver cannot decide, a runtime must decide on every execution. The mechanism there — property maps, hidden classes, inline caches — is the runtime's answer to this lesson's question.