Skip to content

Preserve overload arrow arity with stored call wrappers - #318

Merged
kungfooman merged 13 commits into
mainfrom
overload-arity-wrappers
Nov 4, 2026
Merged

kungfooman merged 13 commits into
mainfrom
overload-arity-wrappers

Conversation

@kungfooman

@kungfooman kungfooman commented Nov 2, 2026 •

Copy link
Copy Markdown
Owner

Fixes #42

Primer

A function with @overload docs promises several call shapes, and the transpiler turns each documented shape into a positional tuple plus template bindings so the runtime can try every signature with independent inference and report one failure when no shape accepts the call. The check must run before the body, so the emitter wraps the function: the wrapper head carries the observable length, name, async, and generator behavior while the inner function keeps the declared parameters and the inspection call reads the actual arguments. Computed keys complicate this because the key expression evaluates once per object or instance creation, and Node 18 does not infer a function name from a computed key where Node 24 does, so the emission captures the coerced key outside the function and routes the value through an inner object literal that restores the name on both engines. A full worked pass over a one-overload arrow plus a computed class field closes this file, and a deep dive on the name-preserving shell comes next: it introduces the shape piece by piece, shows which engine gap each piece covers, and justifies why the ugly wrapper is needed at all.

Why the shell exists

The shell is the ({["action"]: WRAPPER})["action"] object literal around the dummy-head wrapper, emitted only when keepName holds (src-transpiler/Asserter.js overloadArrowHead: a wrapper exists, arrowInferredName returned non-null, and no captured key already covers the name). It looks redundant next to const action = WRAPPER, because a plain binding infers name on both engines. The probes below show where that intuition fails and why each piece of the shape is load-bearing.

  1. JavaScript infers an arrow name from its syntactic position, and the inference table has holes. A probe over one arrow per class-field shape reports name on Node 24 versus Node 18: identifier fields infer "action" on both, private fields infer "#priv" on both, but string-literal fields infer "literal" on 24 and undefined on 18, numeric fields infer "0" on 24 and undefined on 18, and computed fields infer "dynamic" on 24 and undefined on 18. Object-literal string keys infer on both engines, so the hole is specific to class fields with literal or computed keys on Node 18.
  2. The wrapper makes the hole worse because it adds a second arrow between the binding and the name. Without any shell, a static-literal field emits ["literal"] = (rtiParam0, ...) => ..., and the Node 18 probe reads name === undefined off the live field. The original arrow already lost its name at that position; wrapping it in one more anonymous arrow cannot bring the name back. The shell fixes this by giving the wrapper a position that infers on every engine: an object literal with a quoted key. ({["literal"]: WRAPPER})["literal"] evaluates the inner arrow at a ["literal"] property, both engines set name to "literal", and the trailing ["literal"] lookup hands the named function to the field. A probe of the exact shelled shape reports "literal" and "0" on both engines.
  3. Each piece of the shape covers a distinct gap. overloadNameKey quotes through JSON.stringify, so keys that are not valid identifiers survive: "lit-key" keeps its hyphen, "0" keeps its numeric spelling, and "#privateAction" keeps the hash the private field needs (see the #privateAction fixture at test/typechecking/overload-fields-output.mjs:73-99). A bare identifier key would be a syntax error for "lit-key" and would name the private accessor wrong. The trailing lookup spells the same key so evaluation order stays single-shot: the key expression appears once in the property and once in the lookup for static names, and for computed keys it appears as the pre-coerced overloadPropertyKey call, never re-evaluated.
  4. Computed keys skip this shell entirely, and that is deliberate rather than missing. keepName is false when node.rtiOverloadKeyName is set, which is exactly the dynamic case: capturedClassField and the ObjectProperty spread already route the arrow through [rtiOverloadKey]: arrow inside a capture that evaluates the key once per creation. Adding the static shell there would double the wrapper: an outer ({[key]: ...})[key] around an inner (({k} => ({[k]: arrow}))(key))[key], with two key evaluations and two object allocations per instance. The collision and reentrancy specs (overloadedComputedClassFieldCollisions, overloadedComputedClassFieldEvaluation) pin the single-evaluation behavior, so the shell stays off where the capture already restores the name.
  5. Anonymous positions keep a degenerate shell that costs nothing. When arrowInferredName returns "" (a callback argument with no binding to infer from), the emitter still wraps as ({[""]: WRAPPER})[""]: the probe maps [1, 2] through it and gets [2, 4] back on both engines. Dropping the shell only for "" would add a branch to overloadArrowHead for a shape that already works, and the branch would need its own spec to prove it never fires for a named position. The uniform rule (shell whenever no capture covers the name) is one condition instead of two.
  6. Deleting the shell breaks the suite in exactly the way the probes predict. Stubbing keepName to false turns the #privateAction fixture into #privateAction = (rtiParam0, ...) => ..., and the hostile spec testOverloadedArrowMetadata fails on Node 18 with name === undefined where it expects "#privateAction". The failure is engine-specific by construction: the same emission passes on Node 24, which is why the gate runs test_runtime.js on both binaries.

Demonstration

An overloaded arrow with one parameter documents its contract and keeps its metadata:

/**
 * @overload
 * @param {number} value
 * @returns {number}
 */
/** @param {number|string} value */
const action = (value) => typeof value === 'number' ? value : 0;
action(1);
action('bad');

The emitter wraps the arrow and checks the store before the body runs. The object shell survives only for static names, where it restores the quoted key on engines that would otherwise drop it:

const action = ({
  ["action"]: (rtiParam0, ...rtiOverloadRest) => {
    const rtiOverloadStore = [rtiParam0, ...rtiOverloadRest];
    return ((value) => {

    if (!inspectOverloads(rtiOverloadStore, [
      {
        "tuple": {
          "type": "tuple",
          "elements": [
            {
              "type": "tupleMember",
              "name": "value",
              "elementType": "number"
            }
          ]
        },
        "templates": {}
      }
    ], 'action', 'arguments')) {
      youCanAddABreakpointHere();
    }
      return typeof value === 'number' ? value : 0;
    }
    )(rtiParam0, ...rtiOverloadRest);
  }
})["action"];

At runtime action(1) passes silently, action('bad') reports arguments: Argument of type ["bad"] is not assignable to parameter of type [value: number], and action.length is 1 with action.name still action, because the dummy slots keep arity in syntax and the quoted key restores the name.

A plain function declaration needs no wrapper at all: the check inlines into the body, so there is no head, no store, and no shell:

/**
 * @overload
 * @param {number} value
 * @returns {number}
 */
/** @param {number} value */
function take(value) { return value; }
take(1);
take('bad');
function take(value) {
  if (!inspectOverloads([...arguments], [
    {
      "tuple": {
        "type": "tuple",
        "elements": [
          {
            "type": "tupleMember",
            "name": "value",
            "elementType": "number"
          }
        ]
      },
      "templates": {}
    }
  ], 'take', 'arguments')) {
    youCanAddABreakpointHere();
  }
  return value;
}

At runtime take(1) passes and take('bad') warns, with take.length and take.name untouched: the wrapper machinery exists only for arrows, whose expression position carries no name of its own.

A computed class field adds key capture around the same wrapper:

const dynamicField = 'computed';
class OverloadFields {
  /** @overload @param {number} value @returns {number} */
  /** @param {number|string} value */
  [dynamicField] = (value) => typeof value === 'number' ? value : 0;
}
const fields = new OverloadFields();
fields.computed(1);
fields.computed('bad');

The capture carries the coerced key, so the arrow needs no shell of its own: the inner object that holds the key already restores the name. This is the no-shell emission:

[overloadPropertyKey((dynamicField))] = ((rtiOverloadKey) => ({
    [rtiOverloadKey]: (rtiParam0, ...rtiOverloadRest) => {
  const rtiOverloadStore = [rtiParam0, ...rtiOverloadRest];
  return ((value) => {

  if (!inspectOverloads(rtiOverloadStore, [
    {
      "tuple": {
        "type": "tuple",
        "elements": [
          {
            "type": "tupleMember",
            "name": "value",
            "elementType": "number"
          }
        ]
      },
      "templates": {}
    }
  ], 'OverloadFields#dynamicField', 'arguments')) {
    youCanAddABreakpointHere();
  }
    return typeof value === 'number' ? value : 0;
  }
  )(rtiParam0, ...rtiOverloadRest);
}
}))(overloadPropertyKey((dynamicField)))[overloadPropertyKey((dynamicField))];

At runtime fields.computed(1) passes, fields.computed('bad') warns, and fields.computed.length is 1 with fields.computed.name equal to computed on both Node 24 and Node 18, because each instance coerces its own key and the inner carrier object restores the name where the engine would not. Cutting the arrow-level shell for captured keys is what removed the doubled inner wrapper.

What changed

  1. src-runtime/inspectOverloads.js tries each {tuple, templates} signature with fresh template bindings and reports a single failure when none accepts the arguments, src-runtime/overloadPropertyKey.js coerces computed keys for capture, and src-runtime/validateTuple.js validates positional members including optional and variadic slots: before there was no overload runtime path, after overloaded calls validate against the union of documented tuples with per-signature inference; the proof is test/typechecking/overloads-errors.json pinning every throwing call plus test/jsdoc-expect/run.mjs agreeing 44/44 and the sum fixture accepting sum('x', 1, 2) while warning on sum('x', 1, 'bad') and sum(1, 2).
  2. src-transpiler/collectOverloadBlocks.js, src-transpiler/splitOverloadSections.js, src-transpiler/overloadToTuple.js, src-transpiler/overloadSignatures.js, src-transpiler/overloadArity.js, src-transpiler/arrowArity.js, src-transpiler/arrowInferredName.js, src-transpiler/dummyArityHead.js, src-transpiler/overloadInspectionArgs.js, src-transpiler/overloadNameKey.js, src-transpiler/overloadIgnoreSet.js, src-transpiler/eraseIgnoredOverloadParams.js, src-transpiler/freshName.js, and src-transpiler/freshKeyName.js form the overload pipeline from comment blocks to runtime signatures: before Asserter.js built arities, names, and signatures inline, after each branch is a named helper with its own spec, barrel export, and test_runtime.js wiring; the proof is src-transpiler/overloadSignatures.spec.js failing when template bindings are dropped (including the NoInfer joint-inference case that warns twice more) and src-transpiler/dummyArityHead.spec.js failing when collision renaming breaks.
  3. src-transpiler/Asserter.js emits the dummy-head plus stored-list wrapper through overloadArrowHead and the check block through emitOverloadChecks, with computed object and class-field keys captured per creation through the shared key shuttle: before overloaded arrows lost length and computed field arrows lost name on Node 18, after test/typechecking/overload-fields-output.mjs shows the stored-list checks for instance, static, private, and computed fields with per-instance key reads of 1 to 3 and 1 to 5 plus reentrant 2, 6, and 8; the proof is testOverloadedComputedClassFieldChecks, testOverloadedComputedClassFieldSecondInstance, and the collisions plus evaluation hostile specs, all green on Node 24 and Node 18.
  4. test/typechecking/overloads-input.mjs, test/typechecking/overloads-output.mjs, test/typechecking/overload-fields-input.mjs, test/typechecking/overload-fields-output.mjs, and their *-errors.json files pin the runtime contracts including variadic rest, template shadowing, and the NoInfer joint-inference case; the proof is the jsdoc-expect runner failing on any missing or extra throw and the 65/65 parity harness agreeing with the tsc oracle.
  5. AGENTS.md records the extraction bar (a helper file must capture a meaning that can fail on its own, judged by the stub test) and the always-brace control-body rule, and src-runtime/validateTuple.js, src-transpiler/arrowInferredName.spec.js, plus src-transpiler/eraseIgnoredOverloadParams.spec.js comply with the snippet and brace rules with producer-built inputs: before the rules were unwritten and the specs hand-built shapes, after the rules name this branch as precedent and the specs build through overloadToTuple and parse; the proof is npm run lint clean with the new specs failing under the corresponding mutations.

Latent holes the work exposed

  1. Asserter.js keepPlainProperty still gates its capture branch on a shape that never fires for overloaded arrows: plain objects infer on both engines (verified identifier plus coercing keys on Node 24 and 18), so the dead branch is tolerated deliberately with that parity analysis as cover.
  2. JSDoc param parsing re-scans the raw section string to recover rest-ness that expandType erases, with a poison {number[]} ...values shape and a silent dotDot drop for bare and optional rest: the re-scan hid the loss, the tsc parity probes in tmp/jsdoc-parsing.md (git-ignored) exposed it; deferred to its own branch so this PR stays emission plus cleanup with byte-identical outputs.

@kungfooman kungfooman self-assigned this Nov 2, 2026
@kungfooman kungfooman added type assertions Everything related to type assertions. typings Everything related to type declarations runtime transpiler labels Nov 2, 2026
Computed class-field arrows need a per-instance key capture so their
inferred name survives engines without field-key inference (Node 18):
the field key stays a plain coerced expression while the value routes
through an inner object wrapper. The plain path now gates on overload
presence instead of arity, so rest-only arrows capture too.

Adds key-spelling, carrier-collision, and evaluation-order spec files.
…ad paths

overloadSignature held only return {tuple, templates} with one caller and
a spec that pinned construction: stubbing it broke nothing the suite
could name, so the literal goes inline in overloadSignatures. The same
bar applies to overloadInvocation (string concat inlined at the guarded
call site), overloadImplParams (null guard plus parseJSDoc forward), and
dummyArityNames (index loop folded into dummyArityHead, which owns the
collision renaming the loop feeds).

ArrowFunctionExpression and generateTypeChecks shed their overload
blocks into overloadArrowHead and emitOverloadChecks: same file, each
branch now fits on one screen. ClassProperty and ObjectProperty keep
their duplicated capture bodies for per-site overrides.

AGENTS.md gains the extraction bar that drove this: a helper file must
capture a meaning that can fail on its own, adjudicated by the stub
test, with this commit as the deletion precedent and dummyArityHead
plus overloadNameKey as the keeping precedents.
Emptying templates passed test_runtime silently: no unit spec pinned
template content, so only the overloads fixture caught the joint NoInfer
regression. testOwnTemplateSurvivesMapping fails when the mapping drops
the section binding, testOwnTemplateShadowsScope fails when scope merge
order flips or scope-only names are lost.
Bare single-line ifs hide branches from review and from diffs, so the
Test style section now requires braces on every control body. The curly
eslint rule stays off, review enforces. The branch's own
emitOverloadChecks guard complies.
validateTuple braces its two bare optional guards; the inferred-name
specs build their sources as multiline templates; the eraser specs build
tuples through overloadToTuple and fnNodes through parse, per the spec
input rules.
The rest-only wrapper always inferred length zero, so emission
compensated with a runtime setOverloadArrowLength(fn, arity) call. The
dummy-head rewrite declares real leading slots, so length is right
without a call: zero hits across both regenerated outputs while length
stays pinned directly off emitted functions. Removes the header import,
the runChecks harness slots, the helper, its spec, the barrel export,
and the test wiring.
dummyArityHead split its glued prefix into open, store, and entry
parts; ArrowFunctionExpression places each on its own indented line
through numSpaces and this.spaces instead of concatenating a single
line. Regenerates the overload fixtures under the readable emission.
The keepName wrapper opened with inline '({' glue and closed with a
glued '})' suffix, and both capture builders concatenated their brace
tails inline, hiding nesting from the eye and from diffs. Each part now
rides numSpaces and this.spaces on its own line; the brace-heavy capture
tails bind to a named close first, since '}}' inside a template literal
reads as interpolation close plus one literal brace and silently drops
the other. Regenerates the overload fixtures under the readable
emission.
The dummy-head churn around async and generator marks touched the
wrapper entry both engines execute, so the suite now pins each shape
end to end: async arrows await through the wrapper while the check
fires first, extra arguments forward through the stored rest carrier,
block-body async functions keep lexical this, async generators keep
their protocol, and sync generator declarations warn before the first
yield without consuming the iterable. dummyArityHead gains the joined
async-plus-generator entry pin. Each case fails when its mark is
removed: killing asyncMark throws await SyntaxError, killing
generatorMark fails the entry pin.
@kungfooman
kungfooman merged commit a64d333 into main Nov 4, 2026
4 checks passed
@kungfooman
kungfooman deleted the overload-arity-wrappers branch November 4, 2026 13:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

runtime transpiler type assertions Everything related to type assertions. typings Everything related to type declarations

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement JSDoc function overloading runtime type validations

1 participant