Name a function by its definition, not by the binding

`var a = String; a.name` printed "a". Name inference followed any const
binding chain and answered the last identifier, but V8's SetFunctionName
runs only for an anonymous function or class definition: every other value
keeps the name it already has.

The resolution now stops at a named definition, another binding or a member
read, and follows only an anonymous definition's binding, which is the
binding that named the function when it was created. An import alias keeps
that name: `const root = (text) => text` exported from a module and
imported as `builder` is still "root".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-29 05:38:57 -07:00
co-authored by Claude Opus 5.5
parent 9558fcddf3
commit bba843a43a
9 changed files with 94 additions and 9 deletions
@@ -2166,6 +2166,19 @@ function builtinMemberValueName(
return builtinModuleFnOf(lowerer, module, member)?.valueName ?? null;
}
/** Whether `node` is a function, arrow or class DEFINITION that carries no
* name of its own, so the binding or property it initializes names it
* (V8's SetFunctionName). Every other expression evaluates to a value that
* already has whatever name it has. */
function isAnonymousCallableDefinition(node: ts.Node): boolean {
if (ts.isArrowFunction(node)) return true;
return (ts.isFunctionExpression(node) || ts.isClassExpression(node)) && node.name === undefined;
}
/** The name V8 gives a function value read from this expression: its own
* definition, through parenthesized, rebindable and const bindings and
* property reads (`process.cwd`), or the binding an anonymous definition
* initializes. Null when the source does not determine it. */
export function jsFuncNameOf(node: ts.Node, lowerer?: Lowerer): string | null {
let n: ts.Node = node;
while (ts.isParenthesizedExpression(n)) n = n.expression;
@@ -2179,20 +2192,31 @@ export function jsFuncNameOf(node: ts.Node, lowerer?: Lowerer): string | null {
n = declaration;
break;
}
if (
!declaration ||
!ts.isVariableDeclaration(declaration) ||
!declaration.initializer ||
(ts.getCombinedNodeFlags(declaration) & ts.NodeFlags.Const) === 0
)
if (!declaration || !ts.isVariableDeclaration(declaration) || !declaration.initializer) break;
let value: ts.Node = declaration.initializer;
while (ts.isParenthesizedExpression(value)) value = value.expression;
// Name inference (V8's SetFunctionName) covers an ANONYMOUS function or
// class definition and nothing else: the binding names it, so a
// rebindable binding keeps the binding's spelling below. Any other
// initializer is a value that carries its own name — a named
// definition, a builtin function (`var a = String; a.name` is
// "String") or another binding (`var a = b` keeps b's name) — and
// resolution follows it.
if (isAnonymousCallableDefinition(value)) {
// The binding this definition initializes named it when it was
// created, so answer that binding and not the alias the reference
// reached it through: `import b from "./m"` keeps the "root" of
// `const root = (text) => text;` in `m`.
if (ts.isIdentifier(declaration.name)) return declaration.name.text;
break;
n = declaration.initializer;
while (ts.isParenthesizedExpression(n)) n = n.expression;
}
n = value;
}
if (ts.isIdentifier(n)) return n.text;
if (ts.isPropertyAccessExpression(n)) return n.name.text;
if ((ts.isFunctionExpression(n) || ts.isFunctionDeclaration(n)) && n.name) return n.name.text;
if (ts.isFunctionExpression(n) || ts.isArrowFunction(n)) {
if (ts.isClassExpression(n) && n.name) return n.name.text;
if (ts.isFunctionExpression(n) || ts.isArrowFunction(n) || ts.isClassExpression(n)) {
// An anonymous function takes the name of the binding or property it
// initializes, through parentheses (`const f = (function () {})`).
let outer: ts.Node = n;
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/js-function-name-inference.js"
],
"diags": []
}
@@ -0,0 +1,7 @@
{
"order": [
"<repo>/tests/corpus/js-function-name-module-alias/names.js",
"<repo>/tests/corpus/js-function-name-module-alias/main.js"
],
"diags": []
}
@@ -0,0 +1,33 @@
// V8 names a function by its definition: an anonymous function or class
// expression takes the name of the binding or property it initializes, and
// every other value keeps the name it already has. A builtin function bound
// to a variable is therefore still `String`, not the variable's name.
var FromVar = String;
console.log(FromVar.name, FromVar.length, FromVar === String);
let FromLet = Number;
console.log(FromLet.name, FromLet === Number);
const FromConst = Boolean;
console.log(FromConst.name);
var Chained = FromVar;
console.log(Chained.name);
let ThroughParens = (Number);
console.log(ThroughParens.name);
console.log(String.name, Number.name, Boolean.name);
function declared() {}
var Held = declared;
console.log(Held.name);
// Anonymous definitions still take the binding's name.
var Anonymous = function () {};
console.log(Anonymous.name);
let Arrow = () => {};
console.log(Arrow.name);
const Parenthesized = (function () {});
console.log(Parenthesized.name);
const Class = class {};
console.log(Class.name);
var Named = function inner() {};
console.log(Named.name);
const NamedClass = class InnerClass {};
console.log(NamedClass.name);
@@ -0,0 +1,7 @@
// A function imported under a different name keeps the name of the binding
// that created it: V8's SetFunctionName runs once, at the definition.
import builder from "./names.js";
console.log(builder.name);
const alias = builder;
console.log(alias.name);
@@ -0,0 +1,3 @@
const root = (text) => text;
export default root;
@@ -0,0 +1,3 @@
{
"type": "module"
}
@@ -0,0 +1 @@
{ "plain": 2400, "san": 3600 }
@@ -0,0 +1 @@
{ "plain": 3000, "san": 4000 }