Skip to content

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

@hlorellium

Description

@hlorellium

When using {{component}} to curry named args onto a generic class component, the type checker produces TS2589 ("Type instantiation is excessively deep and possibly infinite").

Reproduction

Minimal example:

import Component from '@glimmer/component';
import type { WithBoundArgs } from '@glint/template';

class PickerOption<T> extends Component<{
  Args: { value: T; onSelect: (value: T) => void };
  Blocks: { default: [T] };
}> {
  <template>{{yield @value}}</template>
}

class Picker<T> extends Component<{
  Args: { onSelect: (value: T) => void };
  Blocks: { default: [WithBoundArgs<typeof PickerOption, 'onSelect'>] };
}> {
  <template>
    {{! TS2589: Type instantiation is excessively deep and possibly infinite. }}
    {{yield (component PickerOption onSelect=@onSelect)}}
  </template>
}

A more realistic example — a generic select that curries selection state onto an option component before yielding it:

import Component from '@glimmer/component';
import type { ComponentLike, WithBoundArgs } from '@glint/template';
import { hash } from '@ember/helper';

interface SelectOptionSignature<T> {
  Args: {
    model: T;
    isSelected: boolean;
    didClick: (model: T) => void;
  };
  Blocks: { default: [] };
  Element: HTMLDivElement;
}

class SelectOption<T> extends Component<SelectOptionSignature<T>> {
  <template>
    <div ...attributes role="option" aria-selected={{@isSelected}}>
      {{yield}}
    </div>
  </template>
}

class Select<T> extends Component<{
  Args: {
    selected?: T;
    onChange: (model: T) => void;
  };
  Blocks: {
    default: [{
      Option: WithBoundArgs<
        ComponentLike<SelectOptionSignature<T>>,
        'isSelected' | 'didClick'
      >;
    }];
  };
}> {
  <template>
    {{! TS2589 on the component helper call }}
    {{yield (hash Option=(component SelectOption isSelected=false didClick=@onChange))}}
  </template>
}

Current workaround

Extracting the component into a getter with a manual cast preserves T, but it shouldn't be necessary:

get optionComponent(): ComponentLike<SelectOptionSignature<T>> {
  return SelectOption as ComponentLike<SelectOptionSignature<T>>;
}

Then using {{component this.optionComponent ...}} instead.

Root cause

The emitted TypeScript wraps the first argument to {{component}} in resolveForBind, which extracts the function signature via Parameters<> / ReturnType<>. This erases generic type parameters — TypeScript collapses T to unknown (or triggers TS2589 when the type recursion depth is exceeded during the attempt).

Related issues

Environment

  • @glint/ember-tsc@1.1.1
  • typescript@5.9.3
  • Strict mode .gts files

Possible fix direction

I've been experimenting with a fix locally. The approach: when {{component}} receives a direct class reference (PathExpression) with named args, skip the resolveForBind wrapping and pass the class directly to the keyword's type signature. This lets dedicated ComponentKeyword overloads extract the component's context (args/blocks/element) from the class itself, so T stays intact.

The resolveForBind path stays unchanged for string lookups, no-arg passthrough, and other keywords (helper/modifier). The change is scoped to component + class reference + has named args.

Happy to open a PR if this direction makes sense, or adjust if you'd prefer a different approach.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions