Give generic arrow bindings Node's behavior before their declarations run

Generic arrow and function-expression bindings compile to shared
instances, and their declarations emit no store. Code that can run before
the declaration then diverged from Node.

- Inside a function, a nested function lowered before the declaration
  reads the binding through a TDZ box that nothing initialized. That
  covers closures above the declaration and hoisted function
  declarations. Such a read threw a ReferenceError even after the
  declaration ran. The declaration now stores the instance its declared
  signature pins into that box.
- At module scope, calls and pinned value reads went straight to the
  instances even when a hoisted function or an earlier closure ran them
  before the declaration. A binding with such a use now has a flag that
  its declaration sets.
  - Uses that can run first check the flag.
  - A let or const throws Node's ReferenceError before the arguments
    run.
  - A var runs the arguments and then throws "is not a function".
- Uses that follow the declaration stay direct calls: later statements,
  closures created after it, its own initializer, and function
  declarations that only such code calls.

A var binding inside a function that is called from a place that can run
before its declaration would need a per-call flag shared with its
closures. That shape now fails with SC1090 instead of calling the
instance.
This commit is contained in:
Chris Tate
2026-10-22 02:56:39 -05:00
parent 2eec7f5213
commit 70697b45c3
14 changed files with 531 additions and 5 deletions
@@ -3,15 +3,20 @@ import * as ts from "../ts7/adapter.js";
import { bodyReadsArguments } from "../arguments-usage.js";
import type { Lowerer } from "./lowerer.js";
import {
BOOL,
DYN,
type IrExpr,
type IrFunction,
type IrParam,
type IrStmt,
type IrType,
type SrcLoc,
STRING,
VOID,
isUnitType,
typeEquals,
} from "../../ir/ir.js";
import { nodeThrowExpr } from "../../ir/build.js";
import { isJsSourceFile, locOf } from "../program.js";
import { typeKey } from "../type-mapper.js";
import { functionCanReturnUndefined } from "../function-completion.js";
@@ -22,7 +27,7 @@ import { nsPathPrefix } from "./lower-namespaces.js";
import { declSymbolOf } from "./lower-modules.js";
import { npmStaticPackageOfPath } from "../npm-static.js";
import { rejectStaticThis } from "./static-this.js";
import { jsBindingHasOpenWrites } from "./lower-stmts.js";
import { isVarDeclared, jsBindingHasOpenWrites } from "./lower-stmts.js";
import {
type ParamShape,
paramShapes,
@@ -91,6 +96,15 @@ export interface GenericFnInfo {
* declaration makes a new function object in JavaScript, while the
* compiled instances are shared module functions, so only calls lower. */
functionScoped?: true;
/** The declaration of a generic arrow/function-expression binding. */
bindingDecl?: ts.VariableDeclaration;
/** A module-scope binding that code can use before its declaration
* runs: the BOOL global its declaration sets, and whether it is a
* `let`/`const` (a temporal dead zone) or a `var` (undefined). */
presence?: { flagId: string; lexical: boolean };
/** runsAfterDeclaration's answers for the function declarations whose
* calls it placed. */
ordering?: Map<ts.Node, boolean>;
/** 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
@@ -257,7 +271,14 @@ export function lowerGenericCall(
? implicitCallInstance(lowerer, expr, info)
: genericCallInstance(lowerer, expr, info);
const args = lowerer.completeArgs(expr.arguments.nodes, instance.params, loc, expr);
return { kind: "call", callee: instance.name, args, type: instance.returnType, loc };
const call: IrExpr & { kind: "call" } = {
kind: "call",
callee: instance.name,
args,
type: instance.returnType,
loc,
};
return guardEarlyBindingCall(lowerer, info, expr, call);
}
/** The instance a CALL of a generic function-like names: resolved
@@ -1233,6 +1254,47 @@ export function lowerGenericFnValue(
}
}
if (!target) fenceUnpinned();
return guardEarlyBindingValue(
lowerer,
info,
ref,
pinnedGenericClosure(lowerer, ref, info, target, fenceUnpinned),
);
}
/** The value a generic binding declared inside a function stores when a
* nested function read it before the declaration lowered (`const read =
* () => id; const id: (x: number) => number = <T>(x: T): T => x`): such
* a read resolves to an ordinary TDZ local, so the declaration assigns it
* the instance its concrete declared signature pins. */
export function genericBindingStoredValue(
lowerer: Lowerer,
decl: ts.VariableDeclaration,
info: GenericFnInfo,
): IrExpr {
const name = decl.name as ts.Identifier;
const fenceUnpinned = (): never =>
lowerer.unsupported(
"SC1090",
name,
`generic function bindings read before their declaration without a concrete declared signature (annotate '${info.baseName}' with one, or declare it before its uses)`,
);
const sigs = lowerer.checker.getCallSignatures(lowerer.typeOf(name));
if (sigs.length !== 1) fenceUnpinned();
return pinnedGenericClosure(lowerer, name, info, sigs[0]!, fenceUnpinned);
}
/** The closure of the instance of `info` that `target` pins, for a value
* read at `ref`. `fenceUnpinned` reports a signature that leaves a type
* parameter unbound. */
function pinnedGenericClosure(
lowerer: Lowerer,
ref: ts.Expression,
info: GenericFnInfo,
target: ts.Signature,
fenceUnpinned: () => never,
): IrExpr {
const loc = locOf(ref);
const bindings = new Map<ts.Symbol, IrType>();
unifySignatureBindings(lowerer, info, target, bindings);
const hofBinding = runtimeOptionalHofGenericBinding(lowerer, ref, info);
@@ -2565,9 +2627,18 @@ export function bindingGenericFnInfoOf(
typeParams,
instances: new Map(),
...(functionScoped ? { functionScoped: true as const } : {}),
bindingDecl: decl,
};
lowerer.bindingGenericFns.set(fnNode, info);
lowerer.genericFnsBySymbol.set(sym, info);
// Module-scope uses resolve to the instances from the start, so those
// that can run first need the flag. Inside a function the declaration
// registers before any later use lowers, and earlier uses read a TDZ
// box (genericBindingStoredValue).
if (!functionScoped) {
const presence = bindingPresenceOf(lowerer, info, decl, sym);
if (presence !== undefined) info.presence = presence;
}
if (ts.isFunctionExpression(fnNode) && fnNode.name !== undefined) {
const inner = lowerer.checker.getSymbolAtLocation(fnNode.name);
if (inner) lowerer.genericFnsBySymbol.set(inner, info);
@@ -2575,6 +2646,261 @@ export function bindingGenericFnInfoOf(
return info;
}
/** Whether `use`, a reference to the binding `info` declares, can only
* run after the declaration has run: it follows the declaration in its
* statement list (closures count where they are created), sits inside the
* initializer itself, or is inside a function declaration whose every
* reference does. References from other modules run after this module's
* body, and export specifiers only export. A use the check cannot place
* answers false. */
export function runsAfterDeclaration(lowerer: Lowerer, info: GenericFnInfo, use: ts.Node): boolean {
const decl = info.bindingDecl;
if (decl === undefined) return true;
info.ordering ??= new Map();
return useRunsAfter(lowerer, decl, use, info.ordering, new Set());
}
function useRunsAfter(
lowerer: Lowerer,
decl: ts.VariableDeclaration,
use: ts.Node,
memo: Map<ts.Node, boolean>,
inProgress: Set<ts.Node>,
): boolean {
if (ts.getSourceFileOfNode(use) !== ts.getSourceFileOfNode(decl)) return true;
const init = decl.initializer;
if (init !== undefined && use.pos >= init.pos && use.end <= init.end) return true;
const stmt = decl.parent?.parent;
const list = stmt?.parent;
if (stmt === undefined || list === undefined) return false;
for (let n: ts.Node = use; n.parent !== undefined; n = n.parent) {
if (n.parent === list) return n === stmt ? use.pos >= decl.end : n.pos >= stmt.end;
if (ts.isFunctionDeclaration(n)) return functionRunsAfter(lowerer, decl, n, memo, inProgress);
}
return false;
}
/** Whether every call of the function declaration `fn` runs after `decl`
* has run. A function in progress counts as running after: its calls of
* itself, directly or through other functions, add no earlier caller.
* Only answers that depend on no function in progress are memoized. */
function functionRunsAfter(
lowerer: Lowerer,
decl: ts.VariableDeclaration,
fn: ts.FunctionDeclaration,
memo: Map<ts.Node, boolean>,
inProgress: Set<ts.Node>,
): boolean {
const known = memo.get(fn);
if (known !== undefined) return known;
if (inProgress.has(fn)) return true;
// `export default function () {}`: only importers call it, after this
// module's body has run.
if (fn.name === undefined) return true;
inProgress.add(fn);
let result = true;
try {
const symbol = lowerer.checker.getSymbolAtLocation(fn.name);
if (symbol === undefined) result = false;
else {
for (const ref of bindingReferences(lowerer, ts.getSourceFileOfNode(fn), symbol, fn.name)) {
if (ref.pos >= fn.pos && ref.end <= fn.end) continue;
if (!useRunsAfter(lowerer, decl, ref, memo, inProgress)) {
result = false;
break;
}
}
}
} finally {
inProgress.delete(fn);
}
if (inProgress.size === 0) memo.set(fn, result);
return result;
}
/** The value references of `symbol` in `file` other than `name` (its
* declaration name): identifiers spelled like it that resolve to it,
* outside type positions and export specifiers. */
function bindingReferences(
lowerer: Lowerer,
file: ts.SourceFile,
symbol: ts.Symbol,
name: ts.Identifier,
): ts.Identifier[] {
let byName = lowerer.bindingIdentifiers.get(file);
if (byName === undefined) {
byName = new Map();
lowerer.bindingIdentifiers.set(file, byName);
}
let idents = byName.get(name.text);
if (idents === undefined) {
const found: ts.Identifier[] = [];
ts.walkPreorder(file, (node) => {
if (ts.isIdentifier(node) && node.text === name.text) found.push(node);
return undefined;
});
lowerer.checker.prefetchSymbolNodesExact(found);
byName.set(name.text, found);
idents = found;
}
return idents.filter((ident) => {
if (ident === name) return false;
const parent = ident.parent;
if (parent === undefined || ts.isExportSpecifier(parent) || ts.isExportAssignment(parent))
return false;
let resolved = lowerer.checker.getSymbolAtLocation(ident);
if (ts.isShorthandPropertyAssignment(parent) && parent.name === ident)
resolved = lowerer.checker.getShorthandAssignmentValueSymbol(parent) ?? resolved;
if (resolved !== symbol) return false;
for (let p: ts.Node | undefined = parent; p !== undefined; p = p.parent) {
if (ts.isTypeNode(p) && p.kind !== ts.SyntaxKind.ExpressionWithTypeArguments) return false;
if (ts.isStatement(p) || ts.isFunctionLike(p)) break;
}
return true;
});
}
/** The presence flag of a module-scope generic binding that code can use
* before its declaration runs (a hoisted function or an earlier closure
* called during module evaluation), or undefined when every use runs
* after it. The declaration statement sets the flag. */
function bindingPresenceOf(
lowerer: Lowerer,
info: GenericFnInfo,
decl: ts.VariableDeclaration,
symbol: ts.Symbol,
): GenericFnInfo["presence"] {
const name = decl.name as ts.Identifier;
const early = bindingReferences(lowerer, ts.getSourceFileOfNode(decl), symbol, name).some(
(ref) => !runsAfterDeclaration(lowerer, info, ref),
);
if (!early) return undefined;
const flagId = `%g.${info.qualifiedName}%initialized`;
lowerer.globalsList.push({ id: flagId, name: "%initialized", type: BOOL, mutable: true });
return { flagId, lexical: !isVarDeclared(decl) };
}
/** The statement that records a generic binding's declaration as run,
* for a binding with a presence flag. */
export function genericBindingPresenceStore(
info: GenericFnInfo,
decl: ts.VariableDeclaration,
): IrStmt | null {
if (info.presence === undefined) return null;
const loc = locOf(decl);
return {
kind: "assign",
localId: info.presence.flagId,
value: { kind: "boolLit", value: true, type: BOOL, loc },
loc,
};
}
/** A call of a generic binding where the binding may not be initialized
* yet. A `let`/`const` binding in its temporal dead zone throws Node's
* ReferenceError before the arguments run; an unassigned `var` is
* undefined, so the arguments run and the call throws "is not a
* function". Calls that run after the declaration stay direct. */
function guardEarlyBindingCall(
lowerer: Lowerer,
info: GenericFnInfo,
expr: ts.CallExpression,
call: IrExpr & { kind: "call" },
): IrExpr {
if (info.bindingDecl === undefined || runsAfterDeclaration(lowerer, info, expr)) return call;
const loc = call.loc;
const presence = requirePresence(lowerer, info, expr.expression);
const flag: IrExpr = { kind: "varRef", localId: presence.flagId, type: BOOL, loc };
if (presence.lexical)
return {
kind: "seqExpr",
stmts: [tdzCheck(flag, info.baseName, loc)],
result: call,
type: call.type,
loc,
};
// Arguments first, in order, then the check: Node evaluates them
// before it finds the callee is not callable.
const stmts: IrStmt[] = [];
const args = call.args.map((arg): IrExpr => {
const id = `%earlyCallArg.${lowerer.ctx.locals.length}`;
lowerer.ctx.locals.push({ id, name: "%earlyCallArg", type: arg.type, mutable: false });
stmts.push({ kind: "varDecl", localId: id, init: arg, loc });
return { kind: "varRef", localId: id, type: arg.type, loc };
});
return {
kind: "seqExpr",
stmts,
result: {
kind: "ternary",
cond: flag,
then: { ...call, args },
else_: nodeThrowExpr(1, "", `${info.baseName} is not a function`, call.type, loc),
type: call.type,
loc,
},
type: call.type,
loc,
};
}
/** A generic binding read as a value where it may not be initialized
* yet: a `let`/`const` binding checks its temporal dead zone first. */
function guardEarlyBindingValue(
lowerer: Lowerer,
info: GenericFnInfo,
ref: ts.Expression,
value: IrExpr,
): IrExpr {
if (info.bindingDecl === undefined || runsAfterDeclaration(lowerer, info, ref)) return value;
const presence = requirePresence(lowerer, info, ref);
if (!presence.lexical)
lowerer.unsupported(
"SC1090",
ref,
`generic 'var' bindings read as values before their declaration runs (Node reads undefined there; declare '${info.baseName}' with const)`,
);
const loc = value.loc;
const flag: IrExpr = { kind: "varRef", localId: presence.flagId, type: BOOL, loc };
return {
kind: "seqExpr",
stmts: [tdzCheck(flag, info.baseName, loc)],
result: value,
type: value.type,
loc,
};
}
/** The presence flag a use that can run before the declaration checks.
* Only module-scope bindings have one: a binding inside a function would
* need a per-call flag that its closures share. */
function requirePresence(
lowerer: Lowerer,
info: GenericFnInfo,
blame: ts.Node,
): NonNullable<GenericFnInfo["presence"]> {
if (info.presence !== undefined) return info.presence;
return lowerer.unsupported(
"SC1090",
blame,
`generic arrow/function-expression bindings declared inside functions that code can call before their declaration runs (the binding '${info.baseName}' is a 'var' in a branch or block, or is called from a place that can run first — declare it with const before its uses)`,
);
}
function tdzCheck(flag: IrExpr, name: string, loc: SrcLoc): IrStmt {
return {
kind: "exprStmt",
expr: {
kind: "intrinsic",
name: "module.tdzCheck",
args: [flag, { kind: "strLit", value: name, type: STRING, loc }],
type: VOID,
loc,
},
loc,
};
}
/** `const h = id` — a binding ALIASING a generic function (a top-level
* declaration, a registered generic binding, or another alias — resolved
* left to right in declaration order). The alias registers the SAME info
@@ -138,6 +138,8 @@ import {
bindingGenericFnAliasInfoOf,
bindingGenericFnInfoOf,
bindingGenericFnNodeOf,
genericBindingPresenceStore,
genericBindingStoredValue,
implicitLocalFnInfoOf,
implicitLocalFnNodeOf,
implicitMethodCallInfersReturn,
@@ -5108,8 +5110,19 @@ export function lowerVarDecl(
{
const gfnNode = bindingGenericFnNodeOf(decl) ?? bindingContextualGenericFnNodeOf(lowerer, decl);
if (gfnNode) {
bindingGenericFnInfoOf(lowerer, decl, gfnNode);
return null;
const info = bindingGenericFnInfoOf(lowerer, decl, gfnNode);
// A nested function that lowered before this declaration read the
// binding as an ordinary TDZ box (predeclareForwardCapture); the
// declaration initializes it, so those reads see the function once
// it runs and a ReferenceError before.
const symbol = lowerer.checker.getSymbolAtLocation(decl.name);
const pre = symbol ? lowerer.ctx.tdzPredeclared.get(symbol) : undefined;
if (pre) {
lowerer.ctx.tdzPredeclared.delete(symbol!);
const value = genericBindingStoredValue(lowerer, decl, info);
return { kind: "assign", localId: pre.id, value, initializes: true, loc: locOf(decl) };
}
return genericBindingPresenceStore(info, decl);
}
}
@@ -1906,6 +1906,9 @@ export class Lowerer {
* pinned values resolve through genericFnOf exactly like top-level
* generic function declarations (bindingGenericFnInfoOf). */
readonly bindingGenericFns = new Map<ts.Node, GenericFnInfo>();
/** Identifiers by spelling, per file, for the generic bindings' use
* ordering (runsAfterDeclaration). */
readonly bindingIdentifiers = new Map<ts.SourceFile, Map<string, ts.Identifier[]>>();
/** Per-symbol result of the never-reassigned file scan
* (bindingNeverReassigned — object-literal generic-method receivers). */
readonly neverReassignedCache = new Map<ts.Symbol, boolean>();
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7180-generic-local-binding-early-uses.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7181-generic-module-binding-tdz.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7182-generic-binding-direct-calls.ts"
],
"diags": []
}
@@ -0,0 +1,53 @@
// Generic arrow bindings declared inside functions, used by code that runs
// before or after the declaration: closures created above the declaration
// and hoisted function declarations read the binding like any const, so a
// use before the declaration throws a ReferenceError and a later one sees
// the function.
function describe(error: unknown): string {
return error instanceof Error ? `${error.name}: ${error.message}` : String(error);
}
function make(): (x: number) => number {
const read = (): ((x: number) => number) => id;
const id: (x: number) => number = <T>(x: T): T => x;
return read();
}
console.log(make()(7));
function outer(flag: boolean): number {
if (flag) return helper();
const id: (x: number) => number = <T>(x: T): T => x;
return helper();
function helper(): number {
return id(1);
}
}
console.log(outer(false));
try {
console.log(outer(true));
} catch (error) {
console.log(describe(error));
}
function run(): void {
const early = (): number => id(2);
try {
early();
} catch (error) {
console.log(describe(error));
}
const id: (x: number) => number = <T>(x: T): T => x;
console.log(early());
}
run();
function counter(): () => number {
let total = 0;
const step = (): number => (total = add(total, 1));
const add: (a: number, b: number) => number = <T extends number>(a: T, b: T): number => a + b;
step();
step();
return step;
}
console.log(counter()());
@@ -0,0 +1,49 @@
// A module-scope generic arrow binding is in its temporal dead zone until
// its declaration runs: a hoisted function or an earlier closure that calls
// it during module evaluation throws a ReferenceError, and calls made after
// the declaration reach the function. A `var` binding is undefined until
// then: the call's arguments run, then it throws "is not a function".
function describe(error: unknown): string {
return error instanceof Error ? `${error.name}: ${error.message}` : String(error);
}
function early(): number {
return id(1);
}
const before = (): string => wrap("b");
try {
console.log(early());
} catch (error) {
console.log(describe(error));
}
try {
console.log(before());
} catch (error) {
console.log(describe(error));
}
const id = <T>(x: T): T => x;
let wrap = <T>(x: T): string => `[${String(x)}]`;
console.log(early(), before(), id("direct"), wrap(2));
function later(): string {
return wrap(id(3));
}
console.log(later());
function callMissing(): number {
return missing(log("argument"));
}
function log(text: string): number {
console.log(text);
return 1;
}
try {
console.log(callMissing());
} catch (error) {
console.log(describe(error));
}
var missing = <T>(x: T): T => x;
console.log(callMissing(), missing(5));
@@ -0,0 +1,42 @@
// Generic arrow bindings whose uses all run after the declaration: later
// statements, closures created after it, later declarators, recursion and
// function declarations that only later code calls. These calls go
// straight to the compiled instances.
const pair = <T>(x: T): [T, T] => [x, x];
const doubled = pair(2), labels = pair("a");
console.log(doubled.join(","), labels.join(","));
function useLater(): string {
return pair(true).join(",");
}
console.log(useLater());
export function exported(n: number): number {
return pair(n)[0] + 1;
}
console.log(exported(4));
function local(): string {
var twice = <T extends number>(x: T): number => x * 2;
const call = (): number => twice(4);
const depth = <T>(xs: T[], n: number): number => (n <= 0 ? xs.length : depth(xs, n - 1) + 1);
return `${twice(1)} ${call()} ${depth([1, 2], 3)} ${helper()}`;
function helper(): string {
return pair("h").join("");
}
}
console.log(local());
function loop(): string[] {
const out: string[] = [];
const later: (() => string)[] = [];
for (let i = 0; i < 3; i++) {
const tag = <T>(x: T): string => `#${String(x)}`;
later.push(() => tag("late"));
out.push(tag(i * 10));
}
for (const f of later) out.push(f());
return out;
}
console.log(loop().join(" "));
@@ -60,3 +60,15 @@ function throughParam(a: Amb): void {
console.log(a.m(4));
}
throughParam(amb);
// A `var` binding inside a function, declared in a branch and called from
// a closure: when the branch does not run, Node finds the binding undefined.
// The compiled instances have no per-call flag for that, so the call fences.
function branchVar(flag: boolean): number {
if (flag) {
var picked = <T>(x: T): T => x;
}
const invoke = (): number => picked(1);
return invoke();
}
console.log(branchVar(true));
@@ -92,4 +92,11 @@ generic-value-bindings.ts:60:15 - error SC1090: calls of the generic method 'm'
59 | function throughParam(a: Amb): void {
60 | console.log(a.m(4));
| ^~~~~~
61 | }
61 | }
generic-value-bindings.ts:71:32 - error SC1090: generic arrow/function-expression bindings declared inside functions that code can call before their declaration runs (the binding 'picked' is a 'var' in a branch or block, or is called from a place that can run first — declare it with const before its uses) are not supported yet
70 | }
71 | const invoke = (): number => picked(1);
| ^~~~~~
72 | return invoke();
@@ -0,0 +1 @@
{ "plain": 2542, "san": 2911 }
@@ -0,0 +1 @@
{ "plain": 2500, "san": 2870 }
@@ -0,0 +1 @@
{ "plain": 2686, "san": 3069 }