Skip to content

Include callee name in "not a function" TypeError - #1701

Open
athrvk wants to merge 1 commit into
quickjs-ng:masterfrom
athrvk:claude/quick-js-issue-134-pr-k8dwk7
Open

Include callee name in "not a function" TypeError#1701
athrvk wants to merge 1 commit into
quickjs-ng:masterfrom
athrvk:claude/quick-js-issue-134-pr-k8dwk7

Conversation

@athrvk

@athrvk athrvk commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Problem

When a script calls something that is not callable, QuickJS throws a TypeError with a generic message that gives no clue about which call failed:

TypeError: not a function

A ReferenceError names the missing variable, but the arguably more common failure (obj.foo() where foo doesn't exist, since the property read silently yields undefined) leaves the user searching by hand. Motivated by athrvk/vayu#134, where scripts embedding QuickJS surface this error to end users with no way to tell which of many call sites failed.

After this change

let x = 42; x();                 // TypeError: x is not a function
let o = {};  o.foo();            // TypeError: o.foo is not a function
a.b.c();                         // TypeError: a.b.c is not a function
console.lag("hi");               // TypeError: console.lag is not a function
function F() { this.m(); }       // TypeError: this.m is not a function
"use strict"; return h();        // TypeError: h is not a function   (tail calls)
o["computed"]();                 // TypeError: undefined is not a function
Reflect.apply(123, null, []);    // TypeError: 123 is not a function
[1].map(null);                   // TypeError: null is not a function

Locals, arguments, closure variables, globals, member chains (up to 4 components), this.*, tail calls, and eval-opcode calls with a shadowed callee are all named. This matches what V8/JSC/SpiderMonkey users expect.

How it works

Everything happens on the error path. When the JS_CallInternal() invocation made by a call opcode (OP_call*, OP_tail_call*, OP_call_method, OP_tail_call_method, OP_eval) returns an exception and the callee is not callable, the pending exception must be the generic "not a function" TypeError thrown before anything executed (the only other possibility — an interrupt raised by the initial js_poll_interrupts() — is uncatchable and is left untouched). It is then rethrown with a name reconstructed from the caller's bytecode:

  1. find_stack_slot_writer() runs a forward data-flow analysis over the caller's bytecode — the same graph exploration as compute_stack_size(), which already validated that every position has a statically known stack depth — to find the unique instruction that pushed the callee's stack slot. Writers are joined at control-flow merges, so an ambiguous callee (e.g. (a ? f : g)()) is reported as ambiguous rather than misattributed — a wrong name would be worse than none.
  2. build_callee_name() maps that instruction to a name: variable/property atoms straight from the instruction operands (get_var, get_field2, the fused set_loc forms, …), local/argument names from vardefs, closure variable names from closure_var. For member calls it walks down to the object slots to reconstruct a.b.c.
  3. If no single name can be determined (computed properties o[expr](), ambiguous callees, stripped variable names), primitive callees are described by value instead (undefined is not a function, null is not a function), and everything else keeps the old generic message. The value fallback also applies to the other JS_ThrowTypeErrorNotAFunction() sites (Reflect.apply, array callbacks, proxy apply, iterator methods) — only primitives are stringified there, which cannot run user code.

Why this design (context: #1515)

An earlier upstream attempt (quickjs-ng/quickjs#1515) recorded the last loaded name in JSContext at run time and was abandoned over two review concerns: atom lifetime safety and a measurable 3–5% hit on method calls from per-load bookkeeping. This implementation addresses both by construction:

  • Zero cost when calls succeed. The successful-call path executes exactly the same code as before; the analysis runs only after an exception is already pending. (An earlier draft of this patch pre-checked callability before the call; it measured +6–10% on call-heavy loops and was rejected in favor of this design.)
  • No stored atoms, no reference counting. Names are read directly out of the bytecode and debug tables of the (alive) calling function; nothing is duplicated, cached, or freed across frames.
  • No new state on JSContext/JSRuntime, no bytecode format or serialization changes. Works for qjsc-compiled and JS_ReadObject() bytecode too, degrading gracefully when debug info is stripped.

Validation

  • make test: 0/111 errors (before and after).
  • test262 (test262-fast.conf, strict+nostrict): 52/81192 errors both before and after — failing-test sets diffed and identical, all pre-existing upstream failures.
  • New test_not_a_function() in tests/test_language.js: 19 cases (38 assertions) covering every named form, the fused OP_set_loc case, ambiguity, and the value fallbacks.
  • ASan + UBSan build: full test suite plus targeted edge cases (with blocks, finally/gosub, generators, async, optional calls, direct/indirect eval, constructors) — clean.
  • A user exception whose message happens to be "not a function" thrown inside a callable callee is never rewritten (guarded by the callability check); uncatchable interrupt errors are never replaced.

Performance

Call-focused benchmark (20M iterations/round, best of 5 rounds × 8 interleaved process runs, Release build):

benchmark master this PR
plain call f(x) 43.10 ns 43.05 ns
method call o.m(x) 47.95 ns 46.75 ns

Parity — differences are within run-to-run noise, as expected since the fast path is unchanged. tests/microbench.js shows no change beyond environment noise either.

Notes for reviewers

  • The analysis allocates two small per-bytecode-length tables (6 * byte_code_len bytes) plus a worklist for the duration of one error throw; all allocations go through the non-throwing js_malloc_rt()/js_realloc_rt(), so the analysis can never replace the pending exception with an out-of-memory error — any failure just falls back to the generic message.
  • Backtrace generation is unaffected: the replacement error is created from the same frame state (sf->cur_pc already points past the call instruction), so stack contents and positions are identical to before.
  • Error message texts are not covered by test262, so the message change itself cannot break conformance; the repo's own message assertions (test_constructor) still pass.

Failed calls used to throw a TypeError with a generic message that
gives no clue about which call failed:

    TypeError: not a function

Reconstruct the callee name from the bytecode of the calling function
and include it in the message, like other engines do:

    let o = {}; o.foo();  // TypeError: o.foo is not a function
    let x = 42; x();      // TypeError: x is not a function

The name is derived exclusively on the error path: when the
JS_CallInternal() invocation made by a call instruction returns with
the generic TypeError pending, a data flow analysis of the caller
bytecode - mirroring the graph exploration of compute_stack_size() -
determines which instruction pushed the callee (and, for member calls,
the objects it was looked up on) and the names are read from the
instruction operands, the local variable definitions and the closure
variable list. Successful calls execute exactly the same code as
before, no extra state is kept and no reference counts are touched,
and ambiguous callees (e.g. `(a ? f : g)()`) are never misattributed.

When no single name can be determined (computed properties, ambiguous
callees, stripped variable names), primitive callees are now described
by value ("undefined is not a function", "null is not a function")
and everything else keeps the generic message. This also applies to
the other JS_ThrowTypeErrorNotAFunction() call sites, e.g.
Reflect.apply() and array callbacks.
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