Fix #1068: keep curried generic components from poisoning consumer inference#1196
Conversation
| * 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>; |
There was a problem hiding this comment.
would this not lose type information?
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
This is kind of a bonkers way to do this, I am not aware of any other intermediate format branch that uses JSDoc here
There was a problem hiding this comment.
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.
…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>
b7e541a to
be176df
Compare
Fixes #1068 (the remaining "deep" case from hlorellium's second report)
Where the deep case stands on
mainThe 4-layer
DataTable → Row → Cellrepro no longer produces TS2589 (the v1.2.3bindInvokablefix handled that), but it still fails:Row'sTcollapses to the constraintIdentifiable, so the yielded row item loses its type.The mechanism:
bindInvokabledeliberately keeps the curried component's own type parameter free — that's what lets(component PickerOption onSelect=@onSelect)satisfy a generic-shapedWithBoundArgs<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 & Restintersection params, sequenced two-call currying to fixGivenNamedfirst, 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:
ScopeStackbindings carry metadata;{{#let}}block params initialized frombind-invokablesubexpressions 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.InferenceInertInvokable(Invokable<(...args: any[]) => any>) rather than bareany: 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.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
InferenceInertInvokableand the emit). Sibling-arg checking is unaffected — the showcase test's@glint-expect-errorcases (wrongonSelectcallback, wrongitemselement type, anduser.priceon the yielded param) all still fire, which also provesT=Usergenuinely flows through the currying.Tests
component-curry-generic.test.gts: hlorellium's full showcase (fails onmainat theRowboundary) plus case-A guards for both yield shapes.direct:/yield untouched, shadowing correctness, and the JSDoc form.🤖 Generated with Claude Code