Skip to content

TypeScript/JavaScript: local-variable receiver-type inference doesn't cover typed function parameters (unlike Java/C#/Kotlin/Swift/Scala) #1125

Closed

Description

@inth3shadows

Summary

The local-variable receiver-type inference added in #1108/#1110 covers typed parameters for every supported language except TypeScript/JavaScript (and TSX/JSX, which share the same pattern set). A method call through a typed function parameter — function use(lg: Logger) { lg.log(); } — never resolves via type inference in TS/JS, unlike the identical shape in Java, C#, Kotlin, Swift, and Scala, all of which explicitly support it.

Root cause

// src/resolution/name-matcher.ts:1062-1069
case 'typescript':
case 'javascript':
case 'tsx':
case 'jsx':
  return [
    new RegExp(`\\b${r}\\b\\s*=\\s*new\\s+([A-Za-z_$][\\w.$]*)`), // = new Logger()
    new RegExp(`\\b(?:const|let|var)\\s+${r}\\s*:\\s*([A-Z][\\w.$]*)`), // lg: Logger
  ];

The second pattern requires a leading const|let|var, so it only matches a local variable's own type annotation (const lg: Logger = ...) — never a bare parameter declaration (function use(lg: Logger)), since a parameter has no const/let/var keyword. Compare to Java's equivalent (line ~1078): \b([A-Z][\w.]*)\s+${r}\b\s*[=;,)] — the trailing ) in the character class is exactly what lets it also match a parameter. Kotlin/Swift/Scala go further and drop any keyword requirement at all (\b${r}\b\s*:\s*([A-Z][\w.]*)), matching both val-declarations and typed params uniformly. TS/JS is the only language in this set that excludes the parameter case.

Repro (built v1.2.0, CodeGraph.init + resolveReferences())

// svc.ts
class Logger { log() { return 1; } }
class Other { log() { return 2; } }
export function use(lg: Logger) { return lg.log(); }
export function useOther(o: Other) { return o.log(); }

Callers of both log methods come back empty.

Control: the byte-identical shape in Java —

class Logger { void log() { int a = 1; } }
class Other { void log() { int a = 2; } }
class Use {
  void run(Logger lg) { lg.log(); }
  void runOther(Other o) { o.log(); }
}

— resolves correctly: run → Logger.log, runOther → Other.log. Same construct, same ambiguity (two classes sharing the method name, so no single-candidate fallback can paper over it), only the language differs. This isolates the gap to the TS/JS pattern set specifically.

(Note: a single-class, unambiguous version of the TS repro does resolve — through a same-name fallback strategy elsewhere in the resolver, not through type inference. That fallback is exactly what breaks down once there's a second class with the same method name, which is when type inference is supposed to take over and doesn't.)

Impact

TypeScript/JavaScript is the most common language pair in this project's own userbase, so this is a real precision gap wherever a typed parameter is used as a receiver and its method name collides with another class's method of the same name (a common shape for e.g. handler/service interfaces — process(), handle(), execute()).

Suggested fix direction

Add a parameter-shape pattern mirroring Java's, e.g. \b${r}\s*:\s*([A-Z][\w.$]*)\s*[,)] (or simply drop the const|let|var requirement the way Kotlin/Swift/Scala do) — resolveMethodOnType already validates the inferred type actually declares the method, so loosening this is safe against false positives the same way it is for the other languages.

Verification / scope


@colbymchenry — happy to send a PR for the fix (with a regression test pinning the two-ambiguous-classes/typed-param case), or just leave this filed if you'd rather pick it up yourself. Let me know which you'd prefer.

Activity

  1. inth3shadows commented on Jul 2, 2026

    @inth3shadows
    ContributorAuthor

    Follow-up: instrumented inferLocalReceiverType directly (temp console.error in the compiled resolver, reverted immediately after — no repo changes) to confirm the mechanism rather than just the symptom. For the repro above:

    receiver=lg line=3 -> no match, returning null
    

    Confirms neither TS/JS pattern matches export function use(lg: Logger) { ... } at all — inference returns null outright for a typed-parameter receiver, which is why it falls through to no edge once there's a second class with the same method name.

  2. colbymchenry commented on Jul 2, 2026

    @colbymchenry
    Owner

    Fixed in #1129 (merged to main, ships in the next release).

    Your diagnosis was exactly right, down to the instrumentation — inferLocalReceiverType returning null for a typed-param receiver because the const|let|var prefix excluded the parameter shape. Went with the keyword-free form mirroring Kotlin/Swift/Scala:

    new RegExp(`\\b${r}\\b\\s*:\\s*([A-Z][\\w.$]*)`), // lg: Logger  (annotation or typed param)

    A strict superset of the old pattern, so const lg: Logger still resolves and the typed-param case now does too. It also keeps generics working (repo: Repository<User> → Repository, since the capture stops at <), which is why I didn't go the trailing-[,)]-delimiter route — that would've missed generic-typed params, and those are everywhere in TS. resolveMethodOnType remains the safety net (no edge on a mis-inference), and Swift already ships this identical bare-colon pattern, so this just brings TS/JS in line with the rest of the set.

    The two-ambiguous-classes framing in your repro was the key detail — I built the regression test around it and confirmed it fails without the fix and passes with it (a single-class version passes either way through the same-name fallback, exactly as you called out).

    Thank you again for the consistently excellent reports — isolated repro, a byte-identical control in another language to pin the gap, PR/issue scan, and honest scoping of what you did vs. didn't verify. It makes these trivial to action and genuinely raises the bar for the whole project. Hugely appreciated. 🙏

  3. colbymchenry commented on Jul 2, 2026

    @colbymchenry
    Owner

    Follow-up: your report generalized, so I audited the rest of the supported languages for the same shape and found four more with the identical gap — any language whose receiver-type patterns only matched keyword-anchored locals (let/var/:=/= new) but not the bare parameter form. Fixed all of them in #1130:

    • Rust — fn use(lg: &Logger) (dropped the let anchor; also covers closures |lg: T|)
    • Go — func use(lg Logger) / func (l Logger) M() (PascalCase-guarded, scope-bound)
    • Dart — int use(Logger lg) (widened the trailing delimiter to include )/,)
    • PHP — function use(Logger $lg) (also ?Logger, \App\Logger, &$lg, catch (E $e))

    The rest needed nothing: Python, Java, C#, Kotlin, Swift, Scala, Pascal, Luau already handled params; Ruby, Lua, R, and plain JS/JSX have no typed params; C/C++/Objective-C use separate receiver mechanisms. So the typed-parameter case is now covered across every supported language that has typed parameters. Verified each with a deterministic two-ambiguous-classes probe (Java + Kotlin as passing controls) and a parameterized regression test.

    Your one isolated repro ended up closing a gap in six languages. Thank you again. 🙏

  4. inth3shadows commented on Jul 2, 2026

    @inth3shadows
    ContributorAuthor

    Really appreciate that — and the follow-up audit is honestly the more valuable half of this: catching the same gap in Rust/Go/Dart/PHP before anyone had to file each one separately. Those are all tough finds — the indexer I use myself only covers a handful of languages, so I'm glad what it could surface was enough to point you at the rest.

    And codegraph itself has genuinely changed how I work day to day — this report came out of using it for real, not synthetic bug-hunting. Great tool, genuinely glad you built it. 🙏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions