Skip to content

TypeScript: x.m() links to an imported function x, even when a parameter or local shadows it #2953

Open

Description

@inth3shadows

A member call x.m() links to an imported function named x, as though x itself were called. This happens even when x at the call is a parameter or local that shadows the import.

// streaming.ts
export interface StreamApi { onAbort(f: () => void): void; write(s: string): void; }
export function stream(c: unknown, cb: (s: StreamApi) => Promise<void>): Response { return null as any; }

// app.ts
import { stream } from './streaming';
export function handlerA(c: unknown) {
  return stream(c, async (stream) => {      // line 3
    stream.onAbort(() => {});              // line 4
    stream.write('x');                     // line 5
  });
}
export function handlerC(stream: { onAbort(f: () => void): void }) {
  stream.onAbort(() => {});                // line 14
}
export function localConst() {
  const stream = { onAbort(f: () => void) {} };
  stream.onAbort(() => {});
}
call edge on main (resolvedBy: import) expected
line 3 stream(c, …) stream ✓ stream
line 4 stream.onAbort(…) (arrow parameter) stream ✗ none, or StreamApi.onAbort
line 5 stream.write(…) stream ✗ none, or StreamApi.write
line 14 stream.onAbort(…) (function parameter) stream ✗ none
localConst: stream.onAbort(…) (local const) stream ✗ none

The edge's refName is stream.onAbort, but it resolves through the import binding of the receiver's first segment, so the callee is the receiver's import. The same parameter named s gives s.onAbort → StreamApi.onAbort. Even without shadowing, (stream as any).onAbort() links to stream, but a property of a function object isn't the function.

Reproduced on main at 2577601 with CODEGRAPH_KERNEL=0. Linux/WSL2, Node 22.23.2.

Where it shows up. This is hono's documented streaming API: stream(c, async (stream) => { stream.write(…); stream.onAbort(…) }). In hono (bac5c4a) indexed on 2577601, 45 calls edges have a refName of <name>.<member> and resolve by import to a function named <name>. One is runtime-tests/bun/index.test.tsx:164 stream.onAbort(…) → src/helper/streaming/stream.ts stream. So stream's callers list every line that uses the stream handle. (Another 56 edges in hono resolve <name>.<member>() to an imported constant named <name>, e.g. routes.map(…). I've left those out, since linking a member call to the object it's called on may be intended.)

CommonJS too. The same thing happens through require, where a module exports a function and hangs more functions off it (module.exports = Reply; module.exports.buildReply = buildReply):

// lib/reply.js
function Reply (res) { this.res = res }
function buildReply (R) { return R }
module.exports = Reply
module.exports.buildReply = buildReply
// app.js
const Reply = require('./lib/reply')
function build () { return Reply.buildReply(Reply) }   // → Reply ✗ (expected buildReply)

fastify (5df959f) is written this way. 397 calls edges there resolve X.member() by import to the function X. For example, fastify.js:187 Reply.buildReply(Reply) links to Reply in lib/reply.js rather than to buildReply.

Expected. A member call should not resolve to the receiver's own binding when that binding is a function import. It certainly shouldn't when a parameter or local between the call and the import shadows the name, as with the bare-call shadow rules (#2855, #2732, #2949).

Related: #2949, #2855, #2732.

Activity

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