From 45d3d02bf0bc07f52c927251acb63c8912e51b5f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 21 Oct 2026 01:58:22 -0500 Subject: [PATCH 1/2] Compile generic arrow bindings inside functions that use no enclosing frame A generic arrow or function expression bound inside a function or method compiles when it uses nothing from the enclosing function's frame: no `this`, `super`, `arguments` or `new.target`, and no local, parameter or type parameter of an enclosing function. Its instantiations are module functions, as for a module-scope binding, and may call the binding itself and read module-scope bindings. Instances of bindings in functions and blocks take the declaration's position in their names, so two functions may declare bindings of the same name. Bindings that use the enclosing frame keep their SC1090 fence. Fixes #1624 Co-authored-by: ejc3 <1694850+ejc3@users.noreply.github.com> --- .../frontend/lowering/generic-functions.ts | 107 +++++++++++------- .../generic-local-function-values.ts.json | 6 + tests/corpus/generic-local-function-values.ts | 33 ++++++ tests/diagnostics/generic-value-bindings.ts | 12 +- .../generic-value-bindings.ts.txt | 14 +-- 5 files changed, 120 insertions(+), 52 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-local-function-values.ts.json create mode 100644 tests/corpus/generic-local-function-values.ts diff --git a/packages/compiler/src/frontend/lowering/generic-functions.ts b/packages/compiler/src/frontend/lowering/generic-functions.ts index ac5949656..2cc162342 100644 --- a/packages/compiler/src/frontend/lowering/generic-functions.ts +++ b/packages/compiler/src/frontend/lowering/generic-functions.ts @@ -2210,29 +2210,54 @@ export function implicitLocalFnNodeOf( ); if (redeclared) return null; if (!isConst && !bindingNeverReassigned(lowerer, sym, decl)) return null; - // The capture scan: instances are module functions with no frame. - let captures = false; - const scan = (n: ts.Node): void => { - if (captures) return; - if (n.kind === ts.SyntaxKind.ThisKeyword || n.kind === ts.SyntaxKind.SuperKeyword) { - // Arrow bodies see the ENCLOSING this; function expressions - // rebind their own — but a bare `this` there is untyped JS - // dynamism either way. Reject both, cheaply and soundly. - captures = true; - return; - } - if (ts.isIdentifier(n)) { - const s = lowerer.checker.getSymbolAtLocation(n); - const d = s ? s.valueDeclaration : undefined; - if ( - d && - ts.getSourceFileOfNode(d) === ts.getSourceFileOfNode(decl) && - !(d.pos >= init.pos && d.end <= init.end) - ) { - // Declared outside the initializer, in this file: a capture - // exactly when some enclosing FUNCTION scope declares it — - // module-scope declarations are reachable from any module - // function. + // Instances are module functions with no frame. + return capturesEnclosingFrame(lowerer, init, decl, init.body) ? null : init; + }; + const node = probe(); + if (node === null) { + lowerer.implicitLocalFns.set(decl, null); + return null; + } + return node; +} + +/** True when `fn`, bound by `decl`, uses its enclosing function's frame + * somewhere in `scope`: `this`, `super`, `arguments` or `new.target`, or + * a value or type declared in an enclosing function (a local, a parameter, + * an enclosing generic's type parameter). Module-scope declarations and + * the binding itself are reachable from the module functions the + * instances compile to. */ +function capturesEnclosingFrame( + lowerer: Lowerer, + fn: ts.FunctionExpression | ts.ArrowFunction, + decl: ts.VariableDeclaration, + scope: ts.Node, +): boolean { + const file = ts.getSourceFileOfNode(decl); + const self = lowerer.checker.getSymbolAtLocation(decl.name); + let captures = false; + const scan = (n: ts.Node): void => { + if (captures) return; + if ( + n.kind === ts.SyntaxKind.ThisKeyword || + n.kind === ts.SyntaxKind.SuperKeyword || + ts.isMetaProperty(n) + ) { + // Arrow bodies see the ENCLOSING this; function expressions + // rebind their own — but a bare `this` there is untyped JS + // dynamism either way. Reject both, cheaply and soundly. + captures = true; + return; + } + if (ts.isIdentifier(n)) { + const s = lowerer.checker.getSymbolAtLocation(n); + if (s === undefined) { + if (n.text === "arguments") captures = true; + } else if (s !== self) { + for (const d of s.declarations) { + if (ts.getSourceFileOfNode(d) !== file || (d.pos >= fn.pos && d.end <= fn.end)) continue; + // Declared outside the function, in this file: a capture + // exactly when some enclosing FUNCTION scope declares it. for ( let p: ts.Node | undefined = d.parent; p !== undefined && !ts.isSourceFile(p); @@ -2245,17 +2270,11 @@ export function implicitLocalFnNodeOf( } } } - ts.forEachChild(n, scan); - }; - scan(init.body); - return captures ? null : init; + } + ts.forEachChild(n, scan); }; - const node = probe(); - if (node === null) { - lowerer.implicitLocalFns.set(decl, null); - return null; - } - return node; + scan(scope); + return captures; } /** Registers (or returns) the GenericFnInfo of a qualifying implicit-any @@ -2468,17 +2487,22 @@ export function bindingGenericFnInfoOf( if (existing) return existing; const name = (decl.name as ts.Identifier).text; if (fnNode.asteriskToken) lowerer.unsupported("SC1071", fnNode); + // Inside a function, the compiled instances are module functions, so + // the initializer may not use the enclosing frame. for ( let n: ts.Node | undefined = decl.parent; n !== undefined && !ts.isSourceFile(n); n = n.parent ) { if (ts.isFunctionLike(n)) { - lowerer.unsupported( - "SC1090", - fnNode, - `generic arrow/function-expression bindings declared inside functions (the compiled instantiations of '${name}' are module functions and cannot capture the enclosing frame — declare the binding at module scope)`, - ); + if (capturesEnclosingFrame(lowerer, fnNode, decl, fnNode)) { + lowerer.unsupported( + "SC1090", + fnNode, + `generic arrow/function-expression bindings that use the frames of their enclosing functions (the compiled instantiations of '${name}' are module functions and cannot capture its locals, parameters, type parameters or 'this' — pass them as arguments, or declare the binding at module scope)`, + ); + } + break; } } const sym = lowerer.checker.getSymbolAtLocation(decl.name); @@ -2510,12 +2534,17 @@ export function bindingGenericFnInfoOf( } checkGenericParameterNames(lowerer, fnNode.parameters.nodes, true); const stmt = decl.parent?.parent; // declarator → list → statement (nsPathPrefix wants the statement) + // A binding in a function or block can share its name with another; its + // instances take the declaration's position. + const topLevel = + stmt?.parent !== undefined && (ts.isSourceFile(stmt.parent) || ts.isModuleBlock(stmt.parent)); const info: GenericFnInfo = { decl: fnNode, baseName: name, qualifiedName: lowerer.qualify( ts.getSourceFileOfNode(decl), - nsPathPrefix(stmt ?? decl, decl) + name, + nsPathPrefix(stmt ?? decl, decl) + + (topLevel ? name : `${name}%l${ts.tokenStartOfNode(decl)}`), ), typeParams, instances: new Map(), diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-local-function-values.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-local-function-values.ts.json new file mode 100644 index 000000000..c7f9cd86a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-local-function-values.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/generic-local-function-values.ts" + ], + "diags": [] +} diff --git a/tests/corpus/generic-local-function-values.ts b/tests/corpus/generic-local-function-values.ts new file mode 100644 index 000000000..7ca835a7d --- /dev/null +++ b/tests/corpus/generic-local-function-values.ts @@ -0,0 +1,33 @@ +// Generic arrow and function-expression bindings declared inside functions +// and methods that use nothing from the enclosing frame compile like +// module-scope bindings: each call instantiates them, they may call +// themselves and read module-scope bindings, and two functions may declare +// bindings of the same name. +function append(): void { + const put = (xs: T[], x: T): T[] => [...xs, x]; + console.log(put([1, 2], 3).join(","), put(["a"], "b").join(",")); +} +append(); + +const LIMIT = 3; +function prepend(): void { + const put = (xs: T[], x: T): T[] => [x, ...xs]; + console.log(put([1], 0).join(",")); + const twice = function (x: T): T[] { + return [x, x]; + }; + console.log(twice("z").join(","), twice(7).length); + const count = (xs: T[], n: number): number => (n <= 0 ? xs.length : 1 + count(xs, n - 1)); + console.log(count([1], 3)); + const firstFew = (xs: T[]): T[] => xs.slice(0, LIMIT); + console.log(firstFew([1, 2, 3, 4]).join(","), firstFew(["p", "q"]).join(",")); +} +prepend(); + +class Box { + run(): string { + const wrap = (x: T): { v: T } => ({ v: x }); + return JSON.stringify([wrap(1), wrap("s"), wrap([true])]); + } +} +console.log(new Box().run()); diff --git a/tests/diagnostics/generic-value-bindings.ts b/tests/diagnostics/generic-value-bindings.ts index 28a8fb366..6f8e50fd2 100644 --- a/tests/diagnostics/generic-value-bindings.ts +++ b/tests/diagnostics/generic-value-bindings.ts @@ -19,13 +19,13 @@ console.log(typeof stored); const flipAlias = flip; console.log(flipAlias(5)); -// Declared inside a function: instances are module functions and cannot -// capture the enclosing frame. -function outer(): number { - const inner = (x: T): T => x; - return inner(3); +// Declared inside a function and reading its parameter: instances are +// module functions and cannot capture the enclosing frame. +function outer(base: number): number { + const inner = (x: T): [T, number] => [x, base]; + return inner(3)[1]; } -console.log(outer()); +console.log(outer(4)); // An ambient binding of generic function type now compiles to Node's exact // ReferenceError at the access (the declare-erasure stance — no diagnostic diff --git a/tests/harness/__snapshots__/generic-value-bindings.ts.txt b/tests/harness/__snapshots__/generic-value-bindings.ts.txt index 88c886d78..702ead841 100644 --- a/tests/harness/__snapshots__/generic-value-bindings.ts.txt +++ b/tests/harness/__snapshots__/generic-value-bindings.ts.txt @@ -57,17 +57,17 @@ generic-value-bindings.ts:20:13 - error SC2004: uses of 'flipAlias' inherit the hint: the declaration of 'flipAlias' did not compile — fix the diagnostic reported there and these sites clear with it -generic-value-bindings.ts:25:17 - error SC1090: generic arrow/function-expression bindings declared inside functions (the compiled instantiations of 'inner' are module functions and cannot capture the enclosing frame — declare the binding at module scope) are not supported yet +generic-value-bindings.ts:25:17 - error SC1090: generic arrow/function-expression bindings that use the frames of their enclosing functions (the compiled instantiations of 'inner' are module functions and cannot capture its locals, parameters, type parameters or 'this' — pass them as arguments, or declare the binding at module scope) are not supported yet - 24 | function outer(): number { - 25 | const inner = (x: T): T => x; - | ^~~~~~~~~~~~~~~~~ - 26 | return inner(3); + 24 | function outer(base: number): number { + 25 | const inner = (x: T): [T, number] => [x, base]; + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 26 | return inner(3)[1]; generic-value-bindings.ts:26:10 - error SC2004: uses of 'inner' inherit the blocker on its declaration - 25 | const inner = (x: T): T => x; - 26 | return inner(3); + 25 | const inner = (x: T): [T, number] => [x, base]; + 26 | return inner(3)[1]; | ^~~~~ 27 | } From 3e0863246766be37fb8184f27add8462ff7b84fe Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 21 Oct 2026 02:51:36 -0500 Subject: [PATCH 2/2] Refuse values of generic bindings declared inside functions Each call of a function makes a new function object for a generic arrow or function expression bound inside it, so `make() !== make()` when make returns the binding, and such values are distinct Map and Set keys. The compiled instances are shared module functions, which cannot give that identity, so a value use of such a binding keeps an SC1090 fence that names the reason. Calls of the binding still compile. --- .../frontend/lowering/generic-functions.ts | 17 ++++++++++++++ tests/diagnostics/generic-value-bindings.ts | 9 ++++++++ .../generic-value-bindings.ts.txt | 23 ++++++++++++------- 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/generic-functions.ts b/packages/compiler/src/frontend/lowering/generic-functions.ts index 2cc162342..abcd5a584 100644 --- a/packages/compiler/src/frontend/lowering/generic-functions.ts +++ b/packages/compiler/src/frontend/lowering/generic-functions.ts @@ -87,6 +87,10 @@ export interface GenericFnInfo { * defining literal must sit at module scope (no enclosing frame to * capture). */ objectLiteral?: true; + /** A binding declared inside a function: each evaluation of the + * declaration makes a new function object in JavaScript, while the + * compiled instances are shared module functions, so only calls lower. */ + functionScoped?: true; /** IMPLICIT-ANY monomorphization (npm-static JS): parallel to * decl.parameters — the param's own symbol when the slot is a BINDABLE * implicit-any parameter (untyped, identifier-named, never written in @@ -1184,6 +1188,16 @@ export function lowerGenericFnValue( lowerer.noteEdge(inst.name); return { kind: "closure", fnName: inst.name, captures: [], type: funcType, loc }; } + // Each call of the enclosing function makes a new function object for a + // binding declared inside it (`make() !== make()` when make returns + // it), which one shared compiled instance cannot give. + if (info.functionScoped) { + lowerer.unsupported( + "SC1090", + ref, + `generic arrow/function-expression bindings declared inside functions as values (each call of the enclosing function makes a new '${info.baseName}' function object, while its compiled instances are shared module functions — call '${info.baseName}' directly, or declare it at module scope)`, + ); + } const fenceUnpinned: () => never = () => lowerer.unsupported( "SC1090", @@ -2489,6 +2503,7 @@ export function bindingGenericFnInfoOf( if (fnNode.asteriskToken) lowerer.unsupported("SC1071", fnNode); // Inside a function, the compiled instances are module functions, so // the initializer may not use the enclosing frame. + let functionScoped = false; for ( let n: ts.Node | undefined = decl.parent; n !== undefined && !ts.isSourceFile(n); @@ -2502,6 +2517,7 @@ export function bindingGenericFnInfoOf( `generic arrow/function-expression bindings that use the frames of their enclosing functions (the compiled instantiations of '${name}' are module functions and cannot capture its locals, parameters, type parameters or 'this' — pass them as arguments, or declare the binding at module scope)`, ); } + functionScoped = true; break; } } @@ -2548,6 +2564,7 @@ export function bindingGenericFnInfoOf( ), typeParams, instances: new Map(), + ...(functionScoped ? { functionScoped: true as const } : {}), }; lowerer.bindingGenericFns.set(fnNode, info); lowerer.genericFnsBySymbol.set(sym, info); diff --git a/tests/diagnostics/generic-value-bindings.ts b/tests/diagnostics/generic-value-bindings.ts index 6f8e50fd2..a7de7aa1b 100644 --- a/tests/diagnostics/generic-value-bindings.ts +++ b/tests/diagnostics/generic-value-bindings.ts @@ -27,6 +27,15 @@ function outer(base: number): number { } console.log(outer(4)); +// Used as a value inside a function: each call of the enclosing function +// makes a new function object (`make() !== make()`), and the compiled +// instances are shared module functions. +function make(): (x: number) => number { + const same = (x: T): T => x; + return same; +} +console.log(make() === make()); + // An ambient binding of generic function type now compiles to Node's exact // ReferenceError at the access (the declare-erasure stance — no diagnostic // here; the optional chain cannot guard the root's own throw). diff --git a/tests/harness/__snapshots__/generic-value-bindings.ts.txt b/tests/harness/__snapshots__/generic-value-bindings.ts.txt index 702ead841..04fa9d069 100644 --- a/tests/harness/__snapshots__/generic-value-bindings.ts.txt +++ b/tests/harness/__snapshots__/generic-value-bindings.ts.txt @@ -73,16 +73,23 @@ generic-value-bindings.ts:26:10 - error SC2004: uses of 'inner' inherit the bloc hint: the declaration of 'inner' did not compile — fix the diagnostic reported there and these sites clear with it -generic-value-bindings.ts:39:13 - error SC1090: results that are themselves generic functions ('(y: U) => string' keeps its type parameters — no call-site instantiation pins them, and the returned value would need the producing call's frame; restructure to one generic function taking all arguments) are not supported yet +generic-value-bindings.ts:35:10 - error SC1090: generic arrow/function-expression bindings declared inside functions as values (each call of the enclosing function makes a new 'same' function object, while its compiled instances are shared module functions — call 'same' directly, or declare it at module scope) are not supported yet - 38 | const curried = (x: T) => (y: U): string => `${x}|${y}`; - 39 | console.log(curried(1)("a")); + 34 | const same = (x: T): T => x; + 35 | return same; + | ^~~~~~~~~~~~ + 36 | } + +generic-value-bindings.ts:48:13 - error SC1090: results that are themselves generic functions ('(y: U) => string' keeps its type parameters — no call-site instantiation pins them, and the returned value would need the producing call's frame; restructure to one generic function taking all arguments) are not supported yet + + 47 | const curried = (x: T) => (y: U): string => `${x}|${y}`; + 48 | console.log(curried(1)("a")); | ^~~~~~~~~~ - 40 | + 49 | -generic-value-bindings.ts:51:15 - error SC1090: calls of the generic method 'm' through this receiver (no compiled declaration with a body resolves statically here — ambient 'declare class' and interface-only methods are signature-only, and only class, static, and object-literal generic methods with bodies monomorphize) is not supported yet +generic-value-bindings.ts:60:15 - error SC1090: calls of the generic method 'm' through this receiver (no compiled declaration with a body resolves statically here — ambient 'declare class' and interface-only methods are signature-only, and only class, static, and object-literal generic methods with bodies monomorphize) is not supported yet - 50 | function throughParam(a: Amb): void { - 51 | console.log(a.m(4)); + 59 | function throughParam(a: Amb): void { + 60 | console.log(a.m(4)); | ^~~~~~ - 52 | } \ No newline at end of file + 61 | } \ No newline at end of file