Skip to content

fix(ts): a local annotated as one of a type's properties has that property's type - #2640

Merged
colbymchenry merged 10 commits into
mainfrom
claude/ts-indexed-type-receivers
Oct 19, 2026
Merged

colbymchenry merged 10 commits into
mainfrom
claude/ts-indexed-type-receivers

Conversation

@colbymchenry

@colbymchenry colbymchenry commented Oct 18, 2026 •

Copy link
Copy Markdown
Owner

What

This is the follow-up #2639 left open. TypeScript/JavaScript receiver inference reads a local or parameter annotated as one of a type's properties (editor: IChatWidget['inputEditor']) as the type it indexes (IChatWidget). The call then links to that type's method of the same name. If the type has no such method, the call falls through to a guess by the method's name.

On a fresh vscode index (main 10e316d), each case checked against its declaration:

  • visibility.isVisible(…) with visibility: ISessionChatPillVisibilityService['pullRequests'] links to the service's own isVisible.
  • raw.disconnect() through CopilotSessionWrapper['session'] links to the wrapper's disconnect.
  • chatModel.onDidChange(…) with chatModel: IInlineChatSession['chatModel'] reaches HiddenRangeModel.onDidChange (a folding model), by the method's name alone.
  • editor.executeEdits(…) and editor.getValue() with editor: IChatWidget['inputEditor'] (×11) reach ICodeEditor only by a 0.65 name guess.

Decision

The options I measured:

  • (a) The type it indexes, as main does: the wrong links above.
  • (b) Unknown, so name strategies decide: 30 new 0.65 guesses on vscode, some into test fixtures.
  • (c) No type and no guess: drops the 11 editor.* guesses that happen to be right.
  • (d) The type the property is declared with: that is what the annotation means. This PR does (d) when that type is written, and falls back to (a) when it isn't.

(d) reads the property with tsFieldDeclaration, the same reader this.<field>.<method>() calls use (#1496). It treats the result the way matchTsThisFieldCall does: the call links to that type's method, or to nothing. It is not matched by the method's name. A first version that fell through to the name strategies gave a wrong 0.65 guess for a property declared as an intersection.

Fix

In matchMethodCall's bracket-suffix block (#2639), an indexed type T['p'] / T["p"] goes through tsIndexedPropertyType:

Why the fallback matters: vscode's terminal test has let onCommandExecuted: Emitter<E>. Inference reads the nearer type-literal member let mockPty: { onCommandExecuted: Emitter<E>['event'] } instead. event is a getter of Emitter, and the field reader takes the event: T of fire(event: T) for it. Reading that as the class's type parameter T, and so deciding alone, cost 18 onCommandExecuted.fire / onCommandFinished.fire links that #2641 made correct.

I also tried skipping such type-literal members as declarations. On vscode that retargeted 888 calls and added and removed about 140 more, many of them wrong. Member lines are often what types a destructured local (const { service } = createService(…)), and the backward scan isn't scoped, so skipping them reaches unrelated declarations further up a test file. It's reverted.

Sync parking is unchanged from main. A failed indexed-type call still waits under the module of the type it indexes, where the property and its type are declared, so a change there re-opens it. One case isn't covered: a method added later to the property's type, in another module, doesn't re-open the call.

Validation

Fresh-index A/B vs main 10e316d

vscode (15,358 files): nodes and files are identical, and no name_tail changes. 14 edges removed and 12 added, all calls instance-method:

  • 11 editor.* calls go from 0.65 name guesses to 0.9 typed links, with the same ICodeEditor targets.
  • visibility.isVisible, raw.disconnect and chatModel.onDidChange, the wrong links above, are removed.
  • disposables.add now links to vscode's vs/base/common/lifecycle DisposableStore.
  • The 18 onCommand*.fire links are unchanged.

10 JS/TS repos (folo, vben, milo-ui, ghostfolio, infrahub, bitwarden, medusa, react-native, refine-examples, excalidraw): byte-identical.

Tests

__tests__/ts-array-annotated-receivers.test.ts's indexed-type describe has 8 tests.

Failing on main (run in a detached worktree):

  • ed: Widget['inputEditor'] links ed.executeEdits to CodeEditor.executeEdits, with a DiffEditor.executeEdits decoy;
  • visibility: VisibilityService['pullRequests'] links to Visibility.isVisible, not the service's own isVisible;
  • a property declared as Filter & { isVisible(): boolean } gets no edge, neither the service's method nor a guess;
  • groupIds: Element['groupIds'] on a string[] property gets no Element.filter edge;
  • two widgets whose property files import different same-named CodeEditors each reach their own;
  • two same-named Widgets, imported (once as import type) by two callers, each read their own property and reach the CodeEditor declared beside them.

Guards, which pass on main:

  • logger: Parameters<typeof make>[0] gets no Logger.info name guess;
  • onDone: Emitter<number>['event'], a getter's, with fire(event: T), still reaches Emitter.fire. This one fails without the fallback.

Mutations:

  • reading the property off any same-named class first fails the import test;
  • passing no hint, or only the declaring file, fails the import tests;
  • falling through to name guesses fails the Parameters guard;
  • a name fallback instead of exclusivity fails the intersection test;
  • dropping the type-parameter rule fails the getter case.

Full suite (Windows), run while another heavy program was loading the machine: 573 files passed. 50 tests in 22 files failed, all 5 s timeouts except:

  • the daemon-pid-reuse EBUSY teardown;
  • a mcp-writer-lock race;
  • the MPEG-TS indexing-time check;
  • an EEXIST on a symlink root-identity.test.ts creates.

All 22 files pass when rerun serially (1,113 tests).

🤖 Generated with Claude Code

colbymchenry and others added 5 commits October 18, 2026 17:05
…nks to nothing

`groupIds: ExcalidrawElement["groupIds"]` was read as the type it
indexes, so `groupIds.filter(…)` would reach `ExcalidrawElement.filter`
had the class one, and on vscode `onCommandExecuted.fire(…)` reached an
`Emitter.fire` through an `Emitter<E>['event']`. An indexed type is one
the annotation names without saying: like an outside type's, it links to
nothing and is not matched by the method's name, which for a
`Parameters<typeof f>[0]` local would only guess. The sync parking
doesn't wait for the indexed type's module either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…perty's type

`editor: IChatWidget['inputEditor']` now types `editor` as the
`ICodeEditor` the property is declared with, read off the indexed class
or interface's own lines as `this.field` calls already are, and an array
property follows the array rules. One whose type isn't written in the
project (`Parameters<typeof f>[0]`, a getter) names no type to call or to
guess by the method's name. The sync parking is main's again: an indexed
type's call waits for the indexed type's module, whose declaration
resolution reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`visibility: ISessionChatPillVisibilityService['pullRequests']` is
declared as `ISessionChatPillFilter & { isVisible(…) }`; its head names
no `isVisible`, and the call then went on to a name guess that picked
the service class's own `isVisible`. Like a `this.<field>` call, a call
on an indexed property links to its type's method or to nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
colbymchenry and others added 5 commits October 18, 2026 20:33
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The indexed type is the one the call's file imports, and the property's
type the class the declaring file imports or declares, passed to
resolveMethodOnType like a typed local's import. vscode's
`ComponentFixtureContext['disposableStore']` reached copilot's vendored
`DisposableStore`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An indexed type whose property's type isn't written in the indexed type,
a getter's or a `typeof`, or whose key isn't a name, is read as the type
it indexes, as it was before. The nearest line declaring a name may be a
member of another variable's type: vscode's `let mockPty: {
onCommandExecuted: Emitter<E>['event'] }` sits above calls on the local
`onCommandExecuted`, an `Emitter<E>`, whose `fire` they reach.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… as before

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The field reader takes `Emitter<T>`'s `fire(event: T)` for its `event`,
so `Emitter<E>['event']` read as `T` and decided alone, and vscode's
`onCommandExecuted.fire(...)` lost its link to `Emitter.fire`. A type
parameter of the indexed class names no type, so the annotation reads as
the type it indexes, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@colbymchenry
colbymchenry merged commit 34b17f6 into main Oct 19, 2026
@colbymchenry
colbymchenry deleted the claude/ts-indexed-type-receivers branch October 19, 2026 05:38
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