skip to content

A JavaScript service class passes its own tests but throws "Cannot read properties of undefined" once other modules register its methods as callbacks. How do you confirm the receiver is being lost, and what would you change so this cannot recur across the codebase?

level: seniorimportance: should knowfreq 40%

answer

  1. error names instance state, not input
  2. only the passed path fails
  3. reproduce by detaching in a test
  4. assert the receiver inside the method
  5. remove the hazard, don't document it

basics

~20 s

Confirm it by checking the registration sites for a bare method reference and by reproducing the detachment directly, or by asserting the receiver inside the method. Then remove the hazard at the boundary: export closures or pre-bound handlers instead of raw methods, rather than asking every caller to remember.

solid answer

~50 s

The error shape is the tell: the stack points inside your method, at the first read of instance state, and the failure only happens on the path where the method was passed rather than called. Confirm quickly — grep the registration sites for `something.method` with no call parentheses, reproduce with `const f = svc.method; f()` in a scratch test, or temporarily assert the receiver inside the method (`if (!(this instanceof Service)) throw new TypeError(...)`) so the failure names itself. Note that the receiver is not always undefined: a callback API may pass one of its own, in which case the method silently operates on the wrong object. The durable fix is to change what you export. Hand out closures from a factory, or a small object of pre-bound handlers, so there is no detachable method to misuse; add a unit test that detaches every public method and calls it. Documentation telling callers to use `.bind()` is not a fix.

code

javascript · 16 lines
javascript
class Service {
  constructor(repo) { this.repo = repo; }
  handle(payload) {
    if (!(this instanceof Service)) {
      throw new TypeError('Service#handle called without its receiver');
    }
    return this.repo.save(payload);
  }
}

const svc = new Service({ save: (p) => p });
console.log(svc.handle('ok'));      // 'ok'

const detached = svc.handle;        // what a registration site does
try { detached('ok'); } catch (e) { console.log(e.message); }
// 'Service#handle called without its receiver'

go deeper

for a junior

Learn to recognise the signature: a TypeError about a property of this that only appears when the method was passed rather than called. Check the registration line for a bare method reference.

for a middle

Be able to reproduce it deliberately with a two-line detachment, explain that the receiver comes from the call, and apply the right fix at the right place — call-site wrapper versus pre-binding at construction.

for a senior

Show a diagnosis path that distinguishes an undefined receiver from a wrong one supplied by the API, and change the exported surface so the mistake becomes impossible. Back it with a test that detaches every public method rather than a convention.

for a principal

Own it as a contract question: any API exported as detachable methods pushes a correctness rule onto every consumer. Decide the codebase convention — closures, pre-bound handlers, or lint enforcement — and account for the allocation and debuggability it costs.

## Reading the symptom Three signals together identify a lost receiver rather than a null-data bug: 1. The `TypeError` names a property that is **instance state**, not request data — `Cannot read properties of undefined (reading 'repo')` where `repo` is a field assigned in the constructor. 2. The stack frame is inside your method, at its first `this.` access, and the frame below it belongs to someone else's dispatch loop or a host API, not to your own call site. 3. The direct path works. Tests that call `svc.handle(x)` pass; only the registered path fails. That combination means the method ran, but with no receiver — the reference was extracted at registration time and the object was left behind. ## Confirming it cheaply **Reproduce the detachment.** The whole bug fits in two lines, and it belongs in the test suite permanently: ```js const detached = svc.handle; expect(() => detached({})).not.toThrow(); // fails today ``` **Assert the receiver at the top of the method**, temporarily or permanently: ```js handle(payload) { if (!(this instanceof Service)) { throw new TypeError('Service#handle called without its receiver'); } // ... } ``` This turns an anonymous property error into a message that names the cause, and it works whether the receiver is `undefined` or the wrong object. **Inspect what actually arrived.** `console.log(this)` at the top of the method distinguishes the three real cases: `undefined` (plain call in strict code), the global object or a host object (a callback API supplied its own receiver), or a different instance (someone re-registered a handler taken from another object). **Read the registration sites.** Search for the method name preceded by a dot and *not* followed by `(`. Passing `svc.handle` is the hazard; `() => svc.handle(x)` is not. ## Why the wrong-object case matters Do not stop at "`this` is undefined". Strict functions accept whatever receiver the caller passes, uncoerced, and callback APIs frequently pass one: a DOM event listener is invoked with the element it is attached to, a browser timer callback with the global object, a Node timer callback with a `Timeout` object. When that happens the method does not throw — it reads `undefined` fields off a foreign object and produces wrong results, which is far more expensive to find. That is another argument for the `instanceof` assertion over relying on a `TypeError`. ## Fixing it so it cannot recur The weakest fix is a comment telling consumers to bind. Every new consumer is a new chance to forget, and the failure is at runtime in their code, not yours. Stronger options, in rough order of how much they remove the hazard: **Export closures instead of methods.** A factory that captures state in scope has no receiver to lose: ```js function createService(repo) { let inFlight = 0; function handle(payload) { inFlight++; return repo.save(payload); } return { handle, stats: () => ({ inFlight }) }; } ``` Any function in that object can be detached, stored, or passed anywhere and still works. The cost is one closure set per service instance and the loss of prototype sharing and `instanceof` checks. **Pre-bind at construction.** Keep the class but make the public methods carry their receiver, by binding in the constructor or declaring them as arrow class fields. Consumers cannot detach what is already attached; you pay one function per method per instance. **Bind once at the boundary.** If the class is large and mostly internal, expose only a small adapter — an object of bound or wrapped handlers built where you wire things up — and keep the raw class private. **Back it with a test, not a convention.** A single test that iterates the public surface, detaches each function and invokes it turns "remember to bind" into something CI enforces. Static analysis can also flag a method reference passed without a receiver, which catches it before review. ## The judgment to show Where the fix goes is the interesting part. Fixing it at the call site is right when you are the one consumer and want the class to stay a plain prototype-shared class. Fixing it at the definition is right when the object crosses a boundary into code you do not control — at that point the receiver rule is part of your contract, and the only reliable contract is one that cannot be broken by forgetting. Also watch where you bind: creating a fresh bound function on every pass through a hot path allocates and breaks identity-based caching, so pre-bind once per instance rather than per call.

  • Why prefer an instanceof assertion over just letting the TypeError happen?
    Because the TypeError only appears when the receiver is `undefined`. If a callback API supplies a receiver of its own — an element, the global object, a timer handle — the method runs against the wrong object and returns wrong results silently. An explicit assertion catches both cases and names the cause in its message.
  • What do you give up by moving from a class to a closure factory?
    Prototype sharing, so each instance allocates its own function set; `instanceof` checks and named types for debugging; and easy subclassing or method patching in tests. In exchange you get a surface with no detachable receiver, which is usually the better trade for a small service handed to other modules.
  • Is binding in a hot path an acceptable fix?
    Not as a habit. Every `.bind()` call allocates a new function with a distinct identity, so binding inside a loop or a frequently-re-run function both allocates and defeats anything that caches or de-duplicates by function reference. Bind once per instance at construction and reuse that value instead.

saying these in an interview costs you the question

  • Blames a race condition instead of a missing receiver
  • Assumes the instance was garbage collected before the callback ran
  • Fixes it by documenting that callers must bind
  • Binds a new function on every call in a hot path
  • Stops at 'this is undefined' and misses the wrong-object case

context