Skip to content

Fix #1068: keep curried generic components from poisoning consumer inference#1196

Open
NullVoxPopuli-ai-agent wants to merge 2 commits into
typed-ember:mainfrom
NullVoxPopuli-ai-agent:fix-1068-curried-generic-inference
Open

Fix #1068: keep curried generic components from poisoning consumer inference#1196
NullVoxPopuli-ai-agent wants to merge 2 commits into
typed-ember:mainfrom
NullVoxPopuli-ai-agent:fix-1068-curried-generic-inference

Conversation

@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor

Fixes #1068 (the remaining "deep" case from hlorellium's second report)

Where the deep case stands on main

The 4-layer DataTable → Row → Cell repro no longer produces TS2589 (the v1.2.3 bindInvokable fix handled that), but it still fails: Row's T collapses to the constraint Identifiable, so the yielded row item loses its type.

The mechanism: bindInvokable deliberately keeps the curried component's own type parameter free — that's what lets (component PickerOption onSelect=@onSelect) satisfy a generic-shaped WithBoundArgs<typeof PickerOption, 'onSelect'> target, and the existing #1068 test locks that in. But when the curried value is passed as an arg to another generic component, TypeScript instantiates that free generic to its constraint during inference, and the collapsed candidate beats the correct candidates from sibling args (@item={{item}}).

Why not a type-level fix

I probed five signature shapes for an "instantiating binder" that would pin the curried generic from the bound named args (decomposing with constraint ties, Given & Rest intersection params, sequenced two-call currying to fix GivenNamed first, plus intersecting holistic & instantiated results). None work: TypeScript only performs generic-signature instantiation in context when the target signature is otherwise fully concrete, and any shape expressive enough to compute the rest-args necessarily has free outer type params, which disables it. The two goals — generic-shaped targets need the holistic type, inference-sensitive arg positions need no type — cannot be met by one value type.

They can be met positionally, which is what this PR does, building on the direction @johanrd explored in johanrd/glint#1:

  • ScopeStack bindings carry metadata; {{#let}} block params initialized from bind-invokable subexpressions with named args are marked. Metadata lives in the scope frames themselves, so an inner shadowing binding ({{#let Other as |Bound|}}) correctly clears the mark — a bug in the ref-counted side-map approach.
  • References to marked bindings are cast in arg position only to a new InferenceInertInvokable (Invokable<(...args: any[]) => any>) rather than bare any: the value contributes no inference candidates, but it's still invokable-shaped, so passing it where no invokable belongs remains an error. JSDoc-cast form covers untyped (.gjs) scripts.
  • Yield positions (and everything else) keep the fully-typed reference, so WithBoundArgs<typeof C, K>-shaped targets keep working — both direct {{yield (component ...)}} and {{#let ... as |C|}}{{yield (hash Option=C)}}, covered in the new test.

Known trade-off

At the cast positions, compatibility between the curried component's signature and the consuming arg's declared type is not checked (stated in the docs on InferenceInertInvokable and the emit). Sibling-arg checking is unaffected — the showcase test's @glint-expect-error cases (wrong onSelect callback, wrong items element type, and user.price on the yielded param) all still fire, which also proves T=User genuinely flows through the currying.

Tests

  • component-curry-generic.test.gts: hlorellium's full showcase (fails on main at the Row boundary) plus case-A guards for both yield shapes.
  • Transform unit tests: cast in arg position with direct:/yield untouched, shadowing correctness, and the JSDoc form.
  • All package typechecks, vitest suites, and lint pass (VS Code smoke timeout is the known pre-existing environmental failure).

🤖 Generated with Claude Code

* the curried component's signature and the consuming arg's declared type is
* not checked at that position.
*/
export type InferenceInertInvokable = Invokable<(...args: any[]) => any>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would this not lose type information?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes — deliberately, and only at these exact positions (a {{#let}}-bound curried component passed as an arg). It's load-bearing rather than incidental: any type information the reference carries becomes an inference candidate, and for a curried generic component that candidate is the constraint-collapsed instantiation, which is precisely what breaks the consumer's T (Row<T> inferring Identifiable in the issue's repro).

I tried hard to avoid the loss — five signature shapes for a binder that would instead pin the curried generic from the bound args, summarized in the PR description. None can work: TS only instantiates a generic source signature in context when the target signature is otherwise fully concrete, and the two consumers have contradictory needs (generic-shaped WithBoundArgs<typeof C, K> targets require the free generic; arg-position inference requires no candidates). Positional treatment is the only split I found that satisfies both.

What's retained: the value is still Invokable-shaped (passing it where no invokable belongs errors), every other position including {{yield}} keeps the fully-typed reference, and sibling-arg checking is unaffected — the showcase test's @glint-expect-error cases all still fire. What's lost is only the check of the curried signature against the consuming arg's declared type at that position. Happy to explore a different trade if you see one I've missed.

node.path.tail.length === 0 &&
scope.isCurriedInvokable(node.path.head.name)
) {
if (useJsDoc) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is kind of a bonkers way to do this, I am not aware of any other intermediate format branch that uses JSDoc here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair — dropped in be176df. Untyped scripts now emit the plain reference (no cast at all): the collapse this guards against requires a generic class component, which is a TypeScript-only concern in practice, so .gjs just keeps status quo behavior. The unit test now asserts the JSDoc path emits uncast.

@NullVoxPopuli NullVoxPopuli left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.

NullVoxPopuli and others added 2 commits July 4, 2026 15:16
…consumer inference

A generic class component curried via {{component}} keeps its own type
parameter free in the emitted bindInvokable result -- deliberately, since
that is what lets the curried value satisfy generic-shaped targets like
WithBoundArgs<typeof C, K> (typically reached via {{yield}}). But when a
{{#let}}-bound alias of such a value was passed as an ARG to another generic
component, TypeScript instantiated the free generic to its constraint during
inference, and that collapsed candidate beat (or conflicted with) the correct
candidates from sibling args: in the issue's DataTable -> Row -> Cell chain,
Row's T became Identifiable instead of the enclosing T.

No library-signature shape can express "pin the curried generic from the
bound args" -- TypeScript only instantiates a generic source signature in
context when the target signature is otherwise fully concrete (several
attempted shapes are documented in the PR). Instead, the transform now
tracks {{#let}} block params initialized from bind-invokable subexpressions
with named args (shadow-correct, via ScopeStack binding metadata) and casts
exactly those references, in arg position only, to the new
InferenceInertInvokable type: the value contributes no inference candidates
while remaining an Invokable, and every other position (notably {{yield}})
keeps the fully-typed reference.

Trade-off: at those arg positions, compatibility between the curried
component's signature and the consuming arg's declared type is not checked.

Based on the approach explored in johanrd#1, with a bounded cast
target instead of `any`, shadow-correct scope tracking, and JSDoc-cast
support for untyped scripts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The inference collapse the cast prevents requires a generic class component,
which is a TypeScript-only concern, and `as` casts aren't valid syntax in
the JS intermediate format -- so .gjs keeps status quo behavior instead of
growing a JSDoc-cast special case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@NullVoxPopuli-ai-agent
NullVoxPopuli-ai-agent force-pushed the fix-1068-curried-generic-inference branch from b7e541a to be176df Compare July 4, 2026 19:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

{{component}} with named args on generic class components produces TS2589

2 participants