Semanticsspec

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.

The question

How does an identifier get connected to the thing it names, and where does that get hard?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

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.

What this phase may assume or do

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.

After resolution: identifiers carry symbols, not names
AST — only what later phases match on
VariableDeclaration→ decl#7 total— The declaration node owns the new symbol. Uses of `total` after this point will refer to `decl#7`.
├── Identifier total→ decl#7 local, block scope“total”
└── BinaryExpression +“n + 1”
├── Identifier n→ decl#3 parameter of `main`“n”— Resolved by walking block -> function scope. The walk is over; the answer is recorded and never recomputed.
└── NumberLiteral 1“1”

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

specThese are language-defined rules with real observable differences, not implementation variation. C++ argument-dependent lookup is specified in [basic.lookup.argdep] and is the reason an unqualified call can find a function in a namespace never mentioned in the file; Rust's glob-ambiguity rule is specified so that importing two globs is legal until an ambiguous name is used. Carrying an intuition from one language to another here is reliably wrong — the same source resolves to different entities under different rules.

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.

The hard cases, and how languages resolve themspec
CaseWhy the simple algorithm failsA language's answer
Overload setsA name maps to several declarations; the choice needs argument typesC++/Java/C#: overload resolution ranks candidates by conversion cost, ambiguity is an error
Argument-dependent lookupThe candidate set itself depends on the argument typesC++ only: namespaces of the argument types are added to the search, which is why swap(a, b) finds the right one
Imports and re-exportsVisibility comes from a graph, not from the enclosing textRust/ES modules: follow the re-export chain, detect cycles, error on unresolved
Glob collisionsTwo equally-near candidates with no ordering between themRust: legal to import both, error only if the ambiguous name is *used*
Method on an uninferred typeResolution needs the type; the type may need the resolutionRust: "type annotations needed"; TypeScript: contextual typing; Haskell: defer as a class constraint
Traits / extension methods in scopeA method exists only if a trait or extension is importedRust 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 double overload chooses the int one. 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) and obj[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.Itoa and strconv.FormatInt rather 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 E0425 for the unresolved-name error, and rust-analyzer's go-to-definition, which is the recorded symbol surfaced directly.
  • C++: clang++ -Xclang -ast-dump shows the resolved DeclRefExpr for each use with the declaration it points at; for overloads, -fno-elide-constructors plus reading the dump shows which candidate won.
  • JavaScript: node --experimental-vm-modules aside, the clearest view is a bundler's module graph — esbuild --analyze or Rollup's output shows how re-export chains were followed.
  • Python: python -X importtime script.py shows the import graph being walked; symtable shows how each name in a function was classified before any runtime lookup happens.
  • Our scope viewer at /compilers/scopes highlights, 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.

Every identifier resolves to exactly one declaration.
With overloads it resolves to a set that is narrowed later, and with glob imports it may resolve to several with no winner — which the language must define as an error or a tie-break.
Resolution is a pure function of the file being compiled.
With imports it depends on other modules, and with re-exports on modules those import. A change elsewhere can change resolution here with no edit in this file.
A "method not found" error means the method does not exist.
In Rust or Swift it often means the trait or extension providing it is not imported, or that the receiver's type has not been inferred yet. The method exists; it is not visible from here.

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.

specArgument-dependent lookup is specified C++ behavior and has no equivalent in C, Java, Rust or Go — an unqualified call in C++ may find a function in a namespace that appears nowhere in the file, purely because an argument's type lives there. Rust's rule that glob ambiguity is an error only at the point of *use* is likewise specified. Neither is an implementation quirk, and carrying either intuition to another language produces confidently wrong predictions.
typicalAttaching an error symbol and continuing is what mainstream compilers do, and it is why one typo produces one message rather than forty. It is not universal: some smaller compilers and many teaching implementations abort at the first resolution failure, which is simpler and produces the one-error-per-build experience. If a compiler reports exactly one error per run, this is usually why.
implementationThe resolution errors described — naming the searched scope, suggesting a missing import, listing ambiguous candidates — describe rustc, Clang and the TypeScript compiler as of 2025. Older toolchains, and many language implementations, produce only "undefined: foo". The mechanism is not what differs; the investment in diagnostics is.

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 of x is 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

Domains that do not exist yet
  • Programming Languages & Runtime Internals — Dynamic attribute and method lookup at runtime, and the caches that make it fast
    What 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.