Skip to content

feat(resolution): resolve this.<field>.method() on the field's declared type (TS/JS)#920

Open
tqrcisio wants to merge 1 commit into
colbymchenry:mainfrom
tqrcisio:fix/ts-this-field-method-resolution
Open

feat(resolution): resolve this.<field>.method() on the field's declared type (TS/JS)#920
tqrcisio wants to merge 1 commit into
colbymchenry:mainfrom
tqrcisio:fix/ts-this-field-method-resolution

Conversation

@tqrcisio

@tqrcisio tqrcisio commented Jun 18, 2026

Copy link
Copy Markdown

Problem

A method call on an injected or declared field, like this.userService.findAll(), degrades to the bare name findAll during extraction: the receiver this.userService is a member_expression, not an identifier, so the receiver is dropped. When several classes define a same-named method, which is the norm in NestJS where every service has findAll/create and every repository has findOne, the bare name binds to an arbitrary one. Two failure modes follow, both visible in the graph:

  • the real target is left with zero callers (a false "dead code" signal); and
  • a wrong edge points at a same-named method on an unrelated class.

This is the misbinding #750 describes, in the shape that dominates real-world TypeScript. Minimal repro (two services sharing findAll):

// user.controller.ts
constructor(private readonly userService: UserService) {}
list() { return this.userService.findAll(); }   // bound to ProductService::findAll

Fix

Mirrors the existing Java this.userbo.toLogin2() handling, in two parts:

  1. Extraction (tree-sitter.ts): when a member call's receiver is this.<field>, re-encode the call as <field>.method instead of dropping to the bare method name. Deeper chains (this.a.b.method()) keep the bare name.

  2. Resolution (name-matcher.ts): a TS/JS branch in matchMethodCall recovers the field's declared type from the enclosing class and resolves the method on it. Two declaration sites are covered, both ubiquitous in NestJS:

    • constructor parameter property, constructor(private readonly userService: UserService), read from the constructor method's signature;
    • class-body field, private readonly repo: Repo;, read from the property node's signature.

    Resolution goes through resolveMethodOnType, which validates the method exists on the inferred type (and its supertypes), so a wrong inference yields no edge rather than a wrong one. When a per-app type repeats across a monorepo (apps/billing and apps/admin each with a UserService), the candidate is disambiguated by directory proximity to the call site, the same file-path proximity the bare-name path relied on before re-encoding.

When the field's type cannot be recovered (untyped field, external or generic type), nothing is forced: the ref falls through to the existing strategies, so behavior there is unchanged.

Validation

Graph-level A/B, baseline build (main) vs this build, indexing five open-source NestJS repos. Node count is identical in every repo (the fix adds no nodes):

repo nodes (base = new) calls (base) calls (new) retargeted dropped
nestjs/nest 15570 = 15570 12079 11816 414 249
ghostfolio/ghostfolio 10443 = 10443 3762 3605 289 188
jmcdo29/testing-nestjs 1263 = 1263 292 285 18 7
immich-app/immich (server/) 12091 = 12091 16277 16244 648 28
twentyhq/twenty (packages/twenty-server/) 83182 = 83182 51982 51858 2756 139

About 4,100 call edges across the five repos are retargeted from a wrong/self binding to the correct injected dependency. Verified against source:

  • immich UserAdminService::getSessions: this.sessionRepository.getByUserId(id) was bound to ApiKeyRepository::getByUserId, now SessionRepository::getByUserId (field sessionRepository: SessionRepository).
  • twenty BillingGaugeService: this.twentyConfigService.get(...) was bound to ApplicationConnectionsController::get (a controller), now TwentyConfigService::get.
  • nest RolesGuard::canActivate: this.reflector.get(...) was bound to ConfigService::get, now Reflector::get.
  • ghostfolio PortfolioController::getHoldings: was a self-loop, now PortfolioService::getHoldings.

The dropped edges are bare-name mis-binds, not lost-correct edges. Audited jmcdo29/testing-nestjs: 7/7 drops were calls on an external library type, Mongoose Model<CatDoc> and TypeORM Repository<Cat>, whose create/update/findOne/remove had been bound to same-named controller methods. ghostfolio's drops are dominated by Angular frontend components whose this.x.get() was bound to a backend service or a test mock (HttpClientMock::get); nest's are coincidental binds on common names (this.subject.next() to ExceptionsHandler::next). The net edge-count decrease is wrong-edge removal plus correct retargeting; the raw counts understate the precision gain, because both a corrected edge and a removed wrong edge lower the total.

Tests

__tests__/ts-di-method-resolution.test.ts (new): constructor-injected and class-body-field resolution, same-name disambiguation, and the type-validation path. The existing same-name-disambiguation.test.ts (#764) keeps passing: its NestJS monorepo fixture is exactly this scenario. The only failures on my machine are the pre-existing timing-sensitive mcp-daemon / ppid-watchdog / sync tests, which pass in isolation on a clean checkout as well.

…ed type (TS/JS)

A method call on an injected or declared field, like this.userService.findAll(),
used to degrade to the bare name findAll (the receiver this.userService is a
member_expression, not an identifier, so the receiver was dropped). When several
classes define a same-named method, which is the norm in NestJS where every
service has findAll/create and every repository has findOne, the bare name bound
to an arbitrary one: the real target was left with zero callers and a wrong edge
pointed at an unrelated class. This is the misbinding colbymchenry#750 calls out, in the
form that dominates TypeScript.

Extraction: when a member call's receiver is this.<field>, re-encode it as
<field>.method instead of dropping to the bare method name. This mirrors the
existing Java this.userbo.toLogin2() unwrap.

Resolution: a TS/JS branch in matchMethodCall recovers the field's declared type
from the enclosing class, either the constructor parameter property
(constructor(private readonly userService: UserService)) or a class-body field
(private readonly repo: Repo), and resolves the method on that type via
resolveMethodOnType, which validates the method exists on the type (and its
supertypes), so a wrong inference yields no edge rather than a wrong one. When a
per-app type repeats across a monorepo, the candidate is disambiguated by
directory proximity to the call site. When the type cannot be recovered (untyped
or external/generic field), nothing is forced and the ref falls through to the
existing strategies unchanged.

Validated graph-level (baseline build vs this build) on real open-source NestJS
repos. Node count identical in every repo (the fix adds no nodes). Corrected
call edges retargeted from a wrong/self binding to the injected
Service/Repository/Reflector, verified against source; the edges that disappear
are bare-name mis-binds to external-library methods (Mongoose Model, TypeORM
Repository, RxJS Subject.next) and cross-tier frontend/backend collisions. An
audit of the testing-nestjs drops found 7/7 were wrong edges, zero correct edges
lost.
@tqrcisio tqrcisio force-pushed the fix/ts-this-field-method-resolution branch from f505de3 to af0d820 Compare June 18, 2026 02:20
@tqrcisio

Copy link
Copy Markdown
Author

@colbymchenry when you have a moment, would appreciate your review on this one.

It ports the Java/Kotlin field-injection resolution to TS/JS: this.<field>.method() now resolves on the field's declared type (constructor parameter property or class-body field) instead of degrading to a bare method name. That fixes the NestJS controller -> service -> repository misbinding, which is the TypeScript form of #750 and a common source of false "zero callers".

Validated graph-level (main vs this branch) on five NestJS repos: nestjs/nest, ghostfolio, jmcdo29/testing-nestjs, immich (server), twenty (twenty-server). Node count identical in all five, around 4,100 call edges retargeted wrong->right, and the dropped edges are bare-name mis-binds to external library types (Mongoose Model, TypeORM Repository, RxJS) verified by source audit. Full numbers and method are in the description. Happy to adjust anything.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant